Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/dart_firebase_admin/lib/dart_firebase_admin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export 'src/app.dart'
AppRegistry,
EmulatorClient,
Environment,
FirebaseUserAgentClient,
FirebaseServiceType,
FirebaseService,
CloudTasksEmulatorClient;
3 changes: 3 additions & 0 deletions packages/dart_firebase_admin/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
23 changes: 10 additions & 13 deletions packages/dart_firebase_admin/lib/src/app/firebase_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StreamedResponse> 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();
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -57,7 +51,7 @@ class FirebaseMessagingHttpClient {
Uri.https(host, path),
body: jsonEncode(requestData),
headers: {
..._legacyFirebaseMessagingHeaders,
'access_token_auth': 'true',
'content-type': 'application/json',
},
);
Expand Down
6 changes: 6 additions & 0 deletions packages/dart_firebase_admin/lib/src/utils/utils.dart
Original file line number Diff line number Diff line change
@@ -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<String> generateUpdateMask(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = <BaseRequest>[];
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/<version>', () async {
final captured = <BaseRequest>[];
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 = <BaseRequest>[];
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/<version> fire-admin-dart/<version>',
() async {
final captured = <BaseRequest>[];
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 = <BaseRequest>[];
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/<version>;
// FirebaseUserAgentClient should replace it with the correct value.
final captured = <BaseRequest>[];
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 = <BaseRequest>[];
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 = <BaseRequest>[];
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<BaseRequest> _captured;
final void Function()? _onClose;

@override
googleapis_auth.AccessCredentials get credentials =>
throw UnimplementedError();

@override
Future<StreamedResponse> send(BaseRequest request) async {
_captured.add(request);
return StreamedResponse(const Stream.empty(), 200);
}

@override
void close() => _onClose?.call();
}
Loading