diff --git a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart index 56e3a5cb..fe7bfa47 100644 --- a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart +++ b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart @@ -6,6 +6,7 @@ export 'src/app.dart' AppRegistry, EmulatorClient, Environment, + FirebaseUserAgentClient, FirebaseServiceType, FirebaseService, CloudTasksEmulatorClient; diff --git a/packages/dart_firebase_admin/lib/src/app.dart b/packages/dart_firebase_admin/lib/src/app.dart index ea6792c5..e47b016e 100644 --- a/packages/dart_firebase_admin/lib/src/app.dart +++ b/packages/dart_firebase_admin/lib/src/app.dart @@ -22,6 +22,8 @@ import '../functions.dart'; import '../messaging.dart'; import '../security_rules.dart'; import '../storage.dart'; +import '../version.g.dart'; +import 'utils/utils.dart'; part 'app/app_exception.dart'; part 'app/app_options.dart'; @@ -31,4 +33,5 @@ part 'app/emulator_client.dart'; part 'app/environment.dart'; part 'app/exception.dart'; part 'app/firebase_app.dart'; +part 'app/firebase_user_agent_client.dart'; part 'app/firebase_service.dart'; 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 41c77c89..a6b913b7 100644 --- a/packages/dart_firebase_admin/lib/src/app/firebase_app.dart +++ b/packages/dart_firebase_admin/lib/src/app/firebase_app.dart @@ -107,21 +107,18 @@ class FirebaseApp { final credential = options.credential; // Create authenticated client based on credential type - if (credential != null) { - final serviceAccountCreds = credential.serviceAccountCredentials; - if (serviceAccountCreds != null) { - // Use service account credentials - return googleapis_auth.clientViaServiceAccount( - serviceAccountCreds, + final client = switch (credential) { + Credential(:final serviceAccountCredentials?) => + googleapis_auth.clientViaServiceAccount( + serviceAccountCredentials, scopes, - ); - } - } + ), + _ => googleapis_auth.clientViaApplicationDefaultCredentials( + scopes: scopes, + ), + }; - // Fall back to Application Default Credentials - return googleapis_auth.clientViaApplicationDefaultCredentials( - scopes: scopes, - ); + return FirebaseUserAgentClient(await client); } /// Returns the HTTP client for this app. diff --git a/packages/dart_firebase_admin/lib/src/app/firebase_user_agent_client.dart b/packages/dart_firebase_admin/lib/src/app/firebase_user_agent_client.dart new file mode 100644 index 00000000..45c9e47a --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/app/firebase_user_agent_client.dart @@ -0,0 +1,29 @@ +part of '../app.dart'; + +/// HTTP client wrapper that adds Firebase and Google API client headers for usage tracking. +/// +/// Wraps another HTTP client and injects: +/// - `X-Firebase-Client: fire-admin-dart/{version}` +/// - `X-Goog-Api-Client: gl-dart/{dartVersion} fire-admin-dart/{version}` +/// into every outgoing request so Firebase backend services can identify the SDK. +@internal +class FirebaseUserAgentClient extends BaseClient + implements googleapis_auth.AuthClient { + FirebaseUserAgentClient(this._client); + + final googleapis_auth.AuthClient _client; + + @override + googleapis_auth.AccessCredentials get credentials => _client.credentials; + + @override + Future send(BaseRequest request) { + request.headers['X-Firebase-Client'] = 'fire-admin-dart/$packageVersion'; + request.headers['X-Goog-Api-Client'] = + 'gl-dart/$dartVersion fire-admin-dart/$packageVersion'; + return _client.send(request); + } + + @override + void close() => _client.close(); +} diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart b/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart index 9f06c4d6..0ce7d45d 100644 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart +++ b/packages/dart_firebase_admin/lib/src/messaging/messaging_http_client.dart @@ -1,11 +1,5 @@ part of 'messaging.dart'; -final _legacyFirebaseMessagingHeaders = { - // TODO send version - 'X-Firebase-Client': 'fire-admin-node/12.0.0', - 'access_token_auth': 'true', -}; - /// HTTP client for Firebase Cloud Messaging API operations. /// /// Handles HTTP client management, googleapis API client creation, @@ -57,7 +51,7 @@ class FirebaseMessagingHttpClient { Uri.https(host, path), body: jsonEncode(requestData), headers: { - ..._legacyFirebaseMessagingHeaders, + 'access_token_auth': 'true', 'content-type': 'application/json', }, ); diff --git a/packages/dart_firebase_admin/lib/src/utils/utils.dart b/packages/dart_firebase_admin/lib/src/utils/utils.dart index e7ca8677..ca9d37ec 100644 --- a/packages/dart_firebase_admin/lib/src/utils/utils.dart +++ b/packages/dart_firebase_admin/lib/src/utils/utils.dart @@ -1,3 +1,9 @@ +import 'dart:io'; + +/// The current Dart SDK version in semver format (e.g. "3.3.0"). +String get dartVersion => + Platform.version.split(RegExp('[^0-9]')).take(3).join('.'); + /// Generates the update mask for the provided object. /// Note this will ignore the last key with value undefined. List generateUpdateMask( diff --git a/packages/dart_firebase_admin/test/app/firebase_user_agent_client_test.dart b/packages/dart_firebase_admin/test/app/firebase_user_agent_client_test.dart new file mode 100644 index 00000000..4f1ae441 --- /dev/null +++ b/packages/dart_firebase_admin/test/app/firebase_user_agent_client_test.dart @@ -0,0 +1,173 @@ +import 'dart:async'; + +import 'package:dart_firebase_admin/src/app.dart'; +import 'package:dart_firebase_admin/src/utils/utils.dart'; +import 'package:dart_firebase_admin/version.g.dart'; +import 'package:googleapis_auth/auth_io.dart' as googleapis_auth; +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +void main() { + group('FirebaseUserAgentClient', () { + test('adds X-Firebase-Client header to every request', () async { + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + await client.send(Request('GET', Uri.parse('https://example.com/'))); + + expect(captured.length, 1); + expect( + captured.first.headers['X-Firebase-Client'], + 'fire-admin-dart/$packageVersion', + ); + }); + + test('header value is fire-admin-dart/', () async { + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + await client.send(Request('GET', Uri.parse('https://example.com/'))); + + final value = captured.first.headers['X-Firebase-Client']!; + expect(value, startsWith('fire-admin-dart/')); + expect(value.split('/').last, packageVersion); + }); + + test('adds X-Goog-Api-Client header to every request', () async { + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + await client.send(Request('GET', Uri.parse('https://example.com/'))); + + expect(captured.length, 1); + expect( + captured.first.headers['X-Goog-Api-Client'], + 'gl-dart/$dartVersion fire-admin-dart/$packageVersion', + ); + }); + + test( + 'X-Goog-Api-Client has correct format gl-dart/ fire-admin-dart/', + () async { + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + await client.send(Request('GET', Uri.parse('https://example.com/'))); + + final value = captured.first.headers['X-Goog-Api-Client']!; + final parts = value.split(' '); + expect(parts.length, 2); + expect(parts[0], startsWith('gl-dart/')); + expect(parts[1], startsWith('fire-admin-dart/')); + expect(parts[1].split('/').last, packageVersion); + }, + ); + + test('preserves other headers on the request', () async { + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + final request = Request('POST', Uri.parse('https://example.com/')); + request.headers['content-type'] = 'application/json'; + request.headers['Authorization'] = 'Bearer tok'; + await client.send(request); + + expect(captured.first.headers['content-type'], 'application/json'); + expect(captured.first.headers['Authorization'], 'Bearer tok'); + }); + + test('overwrites any pre-existing X-Firebase-Client header', () async { + // The legacy messaging client used to set fire-admin-node/; + // FirebaseUserAgentClient should replace it with the correct value. + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + final request = Request('POST', Uri.parse('https://example.com/')); + request.headers['X-Firebase-Client'] = 'fire-admin-node/12.0.0'; + await client.send(request); + + expect( + captured.first.headers['X-Firebase-Client'], + 'fire-admin-dart/$packageVersion', + ); + }); + + test('overwrites any pre-existing X-Goog-Api-Client header', () async { + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + final request = Request('POST', Uri.parse('https://example.com/')); + request.headers['X-Goog-Api-Client'] = 'gl-node/18.0.0 fire-admin/12.0.0'; + await client.send(request); + + expect( + captured.first.headers['X-Goog-Api-Client'], + 'gl-dart/$dartVersion fire-admin-dart/$packageVersion', + ); + }); + + test('injects both headers on every individual request', () async { + final captured = []; + final client = FirebaseUserAgentClient(_CapturingAuthClient(captured)); + + await client.send(Request('GET', Uri.parse('https://example.com/1'))); + await client.send(Request('POST', Uri.parse('https://example.com/2'))); + await client.send(Request('PUT', Uri.parse('https://example.com/3'))); + + expect(captured.length, 3); + for (final req in captured) { + expect( + req.headers['X-Firebase-Client'], + 'fire-admin-dart/$packageVersion', + ); + expect( + req.headers['X-Goog-Api-Client'], + 'gl-dart/$dartVersion fire-admin-dart/$packageVersion', + ); + } + }); + + test('delegates close() to the inner client', () async { + var closed = false; + final client = FirebaseUserAgentClient( + _CapturingAuthClient([], onClose: () => closed = true), + ); + + client.close(); + + expect(closed, isTrue); + }); + + test('delegates credentials getter to the inner client', () { + final inner = _CapturingAuthClient([]); + final client = FirebaseUserAgentClient(inner); + + // credentials throws UnimplementedError on our stub — same as EmulatorClient. + expect(() => client.credentials, throwsUnimplementedError); + }); + }); +} + +/// Minimal [googleapis_auth.AuthClient] that records every [BaseRequest] +/// passed to [send] without making real network calls. +class _CapturingAuthClient extends BaseClient + implements googleapis_auth.AuthClient { + _CapturingAuthClient(this._captured, {void Function()? onClose}) + : _onClose = onClose; + + final List _captured; + final void Function()? _onClose; + + @override + googleapis_auth.AccessCredentials get credentials => + throw UnimplementedError(); + + @override + Future send(BaseRequest request) async { + _captured.add(request); + return StreamedResponse(const Stream.empty(), 200); + } + + @override + void close() => _onClose?.call(); +}