diff --git a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart index bff9e7bf..b7e5e14f 100644 --- a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart +++ b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart @@ -22,5 +22,6 @@ export 'src/app.dart' FirebaseService, FirebaseServiceType, FirebaseUserAgentClient, + RefreshTokenCredential, ServiceAccountCredential, envSymbol; diff --git a/packages/dart_firebase_admin/lib/src/app/credential.dart b/packages/dart_firebase_admin/lib/src/app/credential.dart index 43df6368..2831325b 100644 --- a/packages/dart_firebase_admin/lib/src/app/credential.dart +++ b/packages/dart_firebase_admin/lib/src/app/credential.dart @@ -23,6 +23,8 @@ const envSymbol = #_envSymbol; /// - [Credential.fromServiceAccount] - For service account JSON files /// - [Credential.fromServiceAccountParams] - For individual service account parameters /// - [Credential.fromApplicationDefaultCredentials] - For Application Default Credentials (ADC) +/// - [Credential.fromRefreshToken] - For OAuth2 refresh token JSON files +/// - [Credential.fromRefreshTokenParams] - For individual OAuth2 refresh token parameters /// /// The credential is used to authenticate all API calls made by the Admin SDK. sealed class Credential { @@ -115,6 +117,126 @@ sealed class Credential { } } + /// Creates a credential from an OAuth2 refresh token JSON file. + /// + /// The file must contain: + /// - `client_id`: The OAuth2 client ID + /// - `client_secret`: The OAuth2 client secret + /// - `refresh_token`: The refresh token + /// - `type`: The credential type (typically `"authorized_user"`) + /// + /// You can obtain a refresh token JSON file by running: + /// ``` + /// gcloud auth application-default login + /// ``` + /// which writes credentials to `~/.config/gcloud/application_default_credentials.json`. + /// + /// Example: + /// ```dart + /// final credential = Credential.fromRefreshToken( + /// File('path/to/refresh_token.json'), + /// ); + /// ``` + factory Credential.fromRefreshToken(File refreshTokenFile) { + final String raw; + try { + raw = refreshTokenFile.readAsStringSync(); + } on IOException catch (e) { + throw FirebaseAppException( + AppErrorCode.invalidCredential, + 'Failed to read refresh token file: $e', + ); + } + + final Object? json; + try { + json = jsonDecode(raw); + } on FormatException catch (e) { + throw FirebaseAppException( + AppErrorCode.invalidCredential, + 'Failed to parse refresh token JSON: ${e.message}', + ); + } + + if (json + case { + 'client_id': final String clientId, + 'client_secret': final String clientSecret, + 'refresh_token': final String refreshToken, + 'type': final String type, + } + when clientId.isNotEmpty && + clientSecret.isNotEmpty && + refreshToken.isNotEmpty && + type.isNotEmpty) { + return RefreshTokenCredential._( + clientId: clientId, + clientSecret: clientSecret, + refreshToken: refreshToken, + ); + } + + throw FirebaseAppException( + AppErrorCode.invalidCredential, + 'Refresh token file must contain non-empty string fields: ' + '"client_id", "client_secret", "refresh_token", and "type".', + ); + } + + /// Creates a credential from individual OAuth2 refresh token parameters. + /// + /// Parameters: + /// - [clientId]: The OAuth2 client ID + /// - [clientSecret]: The OAuth2 client secret + /// - [refreshToken]: The refresh token + /// - [type]: The credential type (typically `"authorized_user"`) + /// + /// Example: + /// ```dart + /// final credential = Credential.fromRefreshTokenParams( + /// clientId: 'my-client-id', + /// clientSecret: 'my-client-secret', + /// refreshToken: 'my-refresh-token', + /// type: 'authorized_user', + /// ); + /// ``` + factory Credential.fromRefreshTokenParams({ + required String clientId, + required String clientSecret, + required String refreshToken, + String type = 'authorized_user', + }) { + if (clientId.isEmpty) { + throw FirebaseAppException( + AppErrorCode.invalidCredential, + 'Refresh token must contain a non-empty "client_id".', + ); + } + if (clientSecret.isEmpty) { + throw FirebaseAppException( + AppErrorCode.invalidCredential, + 'Refresh token must contain a non-empty "client_secret".', + ); + } + if (refreshToken.isEmpty) { + throw FirebaseAppException( + AppErrorCode.invalidCredential, + 'Refresh token must contain a non-empty "refresh_token".', + ); + } + if (type.isEmpty) { + throw FirebaseAppException( + AppErrorCode.invalidCredential, + 'Refresh token must contain a non-empty "type".', + ); + } + return RefreshTokenCredential._( + clientId: clientId, + clientSecret: clientSecret, + refreshToken: refreshToken, + ); + } + /// Private constructor for sealed class. Credential._(); @@ -170,6 +292,35 @@ final class ServiceAccountCredential extends Credential { String? get serviceAccountId => _serviceAccountCredentials.email; } +/// OAuth2 refresh token credentials for Firebase Admin SDK. +/// +/// Uses a refresh token to obtain and automatically refresh access tokens. +/// Obtain a refresh token file by running `gcloud auth application-default login`. +@internal +final class RefreshTokenCredential extends Credential { + RefreshTokenCredential._({ + required this.clientId, + required this.clientSecret, + required this.refreshToken, + }) : super._(); + + /// The OAuth2 client ID. + final String clientId; + + /// The OAuth2 client secret. + final String clientSecret; + + /// The OAuth2 refresh token. + final String refreshToken; + + @override + googleapis_auth.ServiceAccountCredentials? get serviceAccountCredentials => + null; + + @override + String? get serviceAccountId => null; +} + /// Application Default Credentials for Firebase Admin SDK. /// /// Uses Google Application Default Credentials (ADC) to automatically discover diff --git a/packages/dart_firebase_admin/lib/src/app/firebase_app.dart b/packages/dart_firebase_admin/lib/src/app/firebase_app.dart index c58d34ee..320b0241 100644 --- a/packages/dart_firebase_admin/lib/src/app/firebase_app.dart +++ b/packages/dart_firebase_admin/lib/src/app/firebase_app.dart @@ -127,6 +127,16 @@ class FirebaseApp { serviceAccountCredentials, scopes, ), + RefreshTokenCredential( + :final clientId, + :final clientSecret, + :final refreshToken, + ) => + googleapis_auth.clientViaRefreshToken( + googleapis_auth.ClientId(clientId, clientSecret), + refreshToken, + scopes, + ), _ => googleapis_auth.clientViaApplicationDefaultCredentials( scopes: scopes, ), diff --git a/packages/dart_firebase_admin/pubspec.yaml b/packages/dart_firebase_admin/pubspec.yaml index da558c73..175aaf8b 100644 --- a/packages/dart_firebase_admin/pubspec.yaml +++ b/packages/dart_firebase_admin/pubspec.yaml @@ -19,7 +19,11 @@ dependencies: google_cloud_firestore: ^0.1.0 google_cloud_storage: ^0.6.0 googleapis: ^16.0.0 - googleapis_auth: ^2.2.0 + googleapis_auth: + git: + url: https://github.com/google/googleapis.dart.git + path: googleapis_auth + ref: c3fa07da0841ba75493c59b64aa0aa901541957e googleapis_beta: ^9.0.0 http: ^1.6.0 intl: ^0.20.0 diff --git a/packages/dart_firebase_admin/test/integration/app/firebase_app_prod_test.dart b/packages/dart_firebase_admin/test/integration/app/firebase_app_prod_test.dart index 9abd6f6a..2979e09f 100644 --- a/packages/dart_firebase_admin/test/integration/app/firebase_app_prod_test.dart +++ b/packages/dart_firebase_admin/test/integration/app/firebase_app_prod_test.dart @@ -99,6 +99,65 @@ void main() { ); }); + group('_createDefaultClient – refresh token path', () { + final refreshTokenFile = + Platform.environment['FIREBASE_REFRESH_TOKEN_CREDENTIALS']; + + test( + 'creates an authenticated client via refresh token credential', + () { + return runZoned(() async { + final credential = Credential.fromRefreshToken( + File(refreshTokenFile!), + ); + + final app = FirebaseApp.initializeApp( + name: 'rt-client-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, credential: credential), + ); + + try { + final client = await app.client; + expect(client, isNotNull); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }, + skip: refreshTokenFile != null + ? false + : 'Requires FIREBASE_REFRESH_TOKEN_CREDENTIALS to be set ' + '(path to a refresh token JSON file, e.g. ' + '~/.config/gcloud/application_default_credentials.json)', + timeout: const Timeout(Duration(seconds: 30)), + ); + + test( + 'SDK-created refresh token client is closed when app.close() is called', + () { + return runZoned(() async { + final credential = Credential.fromRefreshToken( + File(refreshTokenFile!), + ); + + final app = FirebaseApp.initializeApp( + name: 'rt-close-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, credential: credential), + ); + + await app.client; + await app.close(); + + expect(app.isDeleted, isTrue); + }, zoneValues: {envSymbol: prodEnv()}); + }, + skip: refreshTokenFile != null + ? false + : 'Requires FIREBASE_REFRESH_TOKEN_CREDENTIALS to be set', + timeout: const Timeout(Duration(seconds: 30)), + ); + }); + group('getProjectId – computeProjectId fallback', () { test( 'falls back to computeProjectId() when no projectId source is configured', diff --git a/packages/dart_firebase_admin/test/unit/app/refresh_token_credential_test.dart b/packages/dart_firebase_admin/test/unit/app/refresh_token_credential_test.dart new file mode 100644 index 00000000..ad5ab5a5 --- /dev/null +++ b/packages/dart_firebase_admin/test/unit/app/refresh_token_credential_test.dart @@ -0,0 +1,219 @@ +// Copyright 2026 Firebase +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:file/memory.dart'; +import 'package:test/test.dart'; + +void main() { + group('Credential.fromRefreshToken', () { + test('throws if file is missing', () { + final fs = MemoryFileSystem.test(); + expect( + () => Credential.fromRefreshToken(fs.file('refresh_token.json')), + throwsA(isA()), + ); + }); + + test('throws if file content is not valid JSON', () { + final fs = MemoryFileSystem.test(); + fs.file('refresh_token.json').writeAsStringSync('not-json'); + expect( + () => Credential.fromRefreshToken(fs.file('refresh_token.json')), + throwsA(isA()), + ); + }); + + test('throws if client_id is missing', () { + final fs = MemoryFileSystem.test(); + fs + .file('refresh_token.json') + .writeAsStringSync( + jsonEncode({ + 'client_secret': 'secret', + 'refresh_token': 'token', + 'type': 'authorized_user', + }), + ); + expect( + () => Credential.fromRefreshToken(fs.file('refresh_token.json')), + throwsA(isA()), + ); + }); + + test('throws if client_secret is missing', () { + final fs = MemoryFileSystem.test(); + fs + .file('refresh_token.json') + .writeAsStringSync( + jsonEncode({ + 'client_id': 'id', + 'refresh_token': 'token', + 'type': 'authorized_user', + }), + ); + expect( + () => Credential.fromRefreshToken(fs.file('refresh_token.json')), + throwsA(isA()), + ); + }); + + test('throws if refresh_token is missing', () { + final fs = MemoryFileSystem.test(); + fs + .file('refresh_token.json') + .writeAsStringSync( + jsonEncode({ + 'client_id': 'id', + 'client_secret': 'secret', + 'type': 'authorized_user', + }), + ); + expect( + () => Credential.fromRefreshToken(fs.file('refresh_token.json')), + throwsA(isA()), + ); + }); + + test('throws if type is missing', () { + final fs = MemoryFileSystem.test(); + fs + .file('refresh_token.json') + .writeAsStringSync( + jsonEncode({ + 'client_id': 'id', + 'client_secret': 'secret', + 'refresh_token': 'token', + }), + ); + expect( + () => Credential.fromRefreshToken(fs.file('refresh_token.json')), + throwsA(isA()), + ); + }); + + test('throws if any field is an empty string', () { + final fs = MemoryFileSystem.test(); + fs + .file('refresh_token.json') + .writeAsStringSync( + jsonEncode({ + 'client_id': '', + 'client_secret': 'secret', + 'refresh_token': 'token', + 'type': 'authorized_user', + }), + ); + expect( + () => Credential.fromRefreshToken(fs.file('refresh_token.json')), + throwsA(isA()), + ); + }); + + test('returns RefreshTokenCredential for valid file', () { + final fs = MemoryFileSystem.test(); + fs + .file('refresh_token.json') + .writeAsStringSync( + jsonEncode({ + 'client_id': 'test-id', + 'client_secret': 'test-secret', + 'refresh_token': 'test-refresh-token', + 'type': 'authorized_user', + }), + ); + + final credential = Credential.fromRefreshToken( + fs.file('refresh_token.json'), + ); + + expect(credential, isA()); + final rt = credential as RefreshTokenCredential; + expect(rt.clientId, 'test-id'); + expect(rt.clientSecret, 'test-secret'); + expect(rt.refreshToken, 'test-refresh-token'); + expect(rt.serviceAccountCredentials, isNull); + expect(rt.serviceAccountId, isNull); + }); + }); + + group('Credential.fromRefreshTokenParams', () { + test('throws if clientId is empty', () { + expect( + () => Credential.fromRefreshTokenParams( + clientId: '', + clientSecret: 'secret', + refreshToken: 'token', + type: 'authorized_user', + ), + throwsA(isA()), + ); + }); + + test('throws if clientSecret is empty', () { + expect( + () => Credential.fromRefreshTokenParams( + clientId: 'id', + clientSecret: '', + refreshToken: 'token', + type: 'authorized_user', + ), + throwsA(isA()), + ); + }); + + test('throws if refreshToken is empty', () { + expect( + () => Credential.fromRefreshTokenParams( + clientId: 'id', + clientSecret: 'secret', + refreshToken: '', + type: 'authorized_user', + ), + throwsA(isA()), + ); + }); + + test('throws if type is empty', () { + expect( + () => Credential.fromRefreshTokenParams( + clientId: 'id', + clientSecret: 'secret', + refreshToken: 'token', + type: '', + ), + throwsA(isA()), + ); + }); + + test('returns RefreshTokenCredential for valid params', () { + final credential = Credential.fromRefreshTokenParams( + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + refreshToken: 'my-refresh-token', + type: 'authorized_user', + ); + + expect(credential, isA()); + final rt = credential as RefreshTokenCredential; + expect(rt.clientId, 'my-client-id'); + expect(rt.clientSecret, 'my-client-secret'); + expect(rt.refreshToken, 'my-refresh-token'); + expect(rt.serviceAccountCredentials, isNull); + expect(rt.serviceAccountId, isNull); + }); + }); +} diff --git a/packages/google_cloud_firestore/pubspec.yaml b/packages/google_cloud_firestore/pubspec.yaml index 4b246635..7b8ffc04 100644 --- a/packages/google_cloud_firestore/pubspec.yaml +++ b/packages/google_cloud_firestore/pubspec.yaml @@ -11,7 +11,11 @@ dependencies: collection: ^1.19.1 google_cloud: ^0.4.0 googleapis: ^16.0.0 - googleapis_auth: ^2.2.0 + googleapis_auth: + git: + url: https://github.com/google/googleapis.dart.git + path: googleapis_auth + ref: c3fa07da0841ba75493c59b64aa0aa901541957e http: ^1.6.0 intl: ^0.20.0 meta: ^1.18.1 diff --git a/pubspec.yaml b/pubspec.yaml index e28814ec..906763c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,13 @@ workspace: - packages/dart_firebase_admin/example - packages/dart_firebase_admin/example_server_app +dependency_overrides: + googleapis_auth: + git: + url: https://github.com/google/googleapis.dart.git + path: googleapis_auth + ref: c3fa07da0841ba75493c59b64aa0aa901541957e + melos: scripts: docs: diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 38bc0b23..cade9a0b 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -7,6 +7,10 @@ set -e # export GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json # export RUN_PROD_TESTS=true # +# To also run the refresh token credential integration tests, set: +# export FIREBASE_REFRESH_TOKEN_CREDENTIALS=~/.config/gcloud/application_default_credentials.json +# (run `gcloud auth application-default login` first if the file doesn't exist) +# # RUN_PROD_TESTS is intentionally never set in CI to avoid quota-heavy tests running there. # WIF tests (gated by hasWifEnv) still run in CI via the google-github-actions/auth step.