Skip to content

[googleapis_auth] feat: Add ImpersonatedAuthClient for service account impersonation#711

Merged
kevmoo merged 10 commits intogoogle:masterfrom
invertase:dart_firebase_admin/issue-158
Feb 10, 2026
Merged

[googleapis_auth] feat: Add ImpersonatedAuthClient for service account impersonation#711
kevmoo merged 10 commits intogoogle:masterfrom
invertase:dart_firebase_admin/issue-158

Conversation

@demolaf
Copy link
Collaborator

@demolaf demolaf commented Feb 9, 2026

Implements service account impersonation via the IAM Credentials API, allowing a source credential to act as a different service account.

Changes

Added clientViaServiceAccountImpersonation() function and ImpersonatedAuthClient class:

// Get source client
final sourceClient = await clientViaServiceAccount(credentials, scopes);

// Create impersonated client
final impersonated = await clientViaServiceAccountImpersonation(
   sourceClient: sourceClient,
   targetServiceAccount: 'target@project.iam.gserviceaccount.com',
   targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
);

// Make authenticated requests as the impersonated account
await impersonated.get(Uri.parse('https://...'));

// Sign data as the impersonated account
final signature = await impersonated.sign([1, 2, 3, 4, 5]);

// Explicitly generate a new access token
final token = await impersonated.generateAccessToken();

Issue 158 (dart_firebase_admin)

@demolaf demolaf marked this pull request as draft February 9, 2026 14:56
@github-actions
Copy link

github-actions bot commented Feb 9, 2026

PR Health

License Headers ✔️
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
Files
no missing headers

All source files should start with a license header.

This check can be disabled by tagging the PR with skip-license-check.

API leaks ✔️

The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.

Package Leaked API symbol Leaking sources

This check can be disabled by tagging the PR with skip-leaking-check.

Breaking changes ✔️
Package Change Current Version New Version Needed Version Looking good?
googleapis_auth Breaking 2.0.0 2.1.0-wip 2.1.0-wip ✔️

This check can be disabled by tagging the PR with skip-breaking-check.

Unused Dependencies ✔️
Package Status
googleapis_auth ✔️ All dependencies utilized correctly.

For details on how to fix these, see dependency_validator.

This check can be disabled by tagging the PR with skip-unused-dependencies-check.

Changelog Entry ✔️
Package Changed Files

Changes to files need to be accounted for in their respective changelogs.

This check can be disabled by tagging the PR with skip-changelog-check.

@demolaf demolaf force-pushed the dart_firebase_admin/issue-158 branch from b2edf67 to 4772852 Compare February 10, 2026 17:24
Comment on lines +85 to +93
final AuthClient _sourceClient;
final String _targetServiceAccount;
final List<String> _targetScopes;
final List<String>? _delegates;
final String _universeDomain;
final Duration _lifetime;

AccessCredentials _credentials;
http.Client? _authClient;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I LOVE your code organization.

final bits grouped.
Then mutable bits.

Kudos! 🤘

}) : _sourceClient = sourceClient,
_targetServiceAccount = targetServiceAccount,
_targetScopes = List.unmodifiable(targetScopes),
_delegates = delegates != null ? List.unmodifiable(delegates) : null,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're AMAZING. never trust the list to not be mutated. Again, kudos.

Copy link
Collaborator

@kevmoo kevmoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments

@kevmoo
Copy link
Collaborator

kevmoo commented Feb 10, 2026

Here are Gemini's suggested fixes

diff --git a/googleapis_auth/lib/src/iam_signer.dart b/googleapis_auth/lib/src/iam_signer.dart
index bfbaaae36..19405b540 100644
--- a/googleapis_auth/lib/src/iam_signer.dart
+++ b/googleapis_auth/lib/src/iam_signer.dart
@@ -62,10 +62,11 @@ class IAMSigner {
   IAMSigner(
     http.Client client, {
     String? serviceAccountEmail,
-    String endpoint = 'https://iamcredentials.$defaultUniverseDomain',
+    String? endpoint,
+    String universeDomain = defaultUniverseDomain,
   }) : _client = client,
        _serviceAccountEmail = serviceAccountEmail,
-       _endpoint = endpoint;
+       _endpoint = endpoint ?? 'https://iamcredentials.$universeDomain';
 
   /// Returns the service account email.
   ///
diff --git a/googleapis_auth/lib/src/impersonated_auth_client.dart b/googleapis_auth/lib/src/impersonated_auth_client.dart
index 6ea8d265e..c3295b8e1 100644
--- a/googleapis_auth/lib/src/impersonated_auth_client.dart
+++ b/googleapis_auth/lib/src/impersonated_auth_client.dart
@@ -169,15 +169,13 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
       'Failed to generate access token for impersonated service account.',
     );
 
-    final accessToken = responseJson['accessToken'] as String?;
-    final expireTime = responseJson['expireTime'] as String?;
-
-    if (accessToken == null || expireTime == null) {
-      throw ServerRequestFailedException(
+    final (accessToken, expireTime) = switch (responseJson) {
+      {'accessToken': final String t, 'expireTime': final String e} => (t, e),
+      _ => throw ServerRequestFailedException(
         'IAM generateAccessToken response missing required fields.',
         responseContent: responseJson,
-      );
-    }
+      ),
+    };
 
     // Parse RFC 3339 timestamp
     final expiry = DateTime.parse(expireTime);
@@ -203,7 +201,7 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
     final signer = IAMSigner(
       _sourceClient,
       serviceAccountEmail: _targetServiceAccount,
-      endpoint: _universeDomain,
+      universeDomain: _universeDomain,
     );
     return signer.sign(data);
   }
diff --git a/googleapis_auth/test/iam_signer_test.dart b/googleapis_auth/test/iam_signer_test.dart
index 1e067bf9c..4c5747908 100644
--- a/googleapis_auth/test/iam_signer_test.dart
+++ b/googleapis_auth/test/iam_signer_test.dart
@@ -161,6 +161,39 @@ void main() {
       expect(signature, equals(base64Encode([99, 88, 77])));
     });
 
+    test('sign with custom universe domain', () async {
+      final mockClient = MockClient(
+        expectAsync1((request) async {
+          if (request.method == 'POST') {
+            expect(
+              request.url.toString(),
+              equals(
+                'https://iamcredentials.example.com/v1/projects/-/serviceAccounts/custom%40example.iam.gserviceaccount.com:signBlob',
+              ),
+            );
+
+            return http.Response(
+              jsonEncode({
+                'signedBlob': base64Encode([11, 22, 33]),
+              }),
+              200,
+              headers: {'content-type': 'application/json'},
+            );
+          }
+          fail('Unexpected request: ${request.method} ${request.url}');
+        }),
+      );
+
+      final signer = IAMSigner(
+        mockClient,
+        serviceAccountEmail: 'custom@example.iam.gserviceaccount.com',
+        universeDomain: 'example.com',
+      );
+
+      final signature = await signer.sign([5, 6, 7]);
+      expect(signature, equals(base64Encode([11, 22, 33])));
+    });
+
     test('sign with custom endpoint', () async {
       final mockClient = MockClient(
         expectAsync1((request) async {

@demolaf
Copy link
Collaborator Author

demolaf commented Feb 10, 2026

Here are Gemini's suggested fixes

diff --git a/googleapis_auth/lib/src/iam_signer.dart b/googleapis_auth/lib/src/iam_signer.dart
index bfbaaae36..19405b540 100644
--- a/googleapis_auth/lib/src/iam_signer.dart
+++ b/googleapis_auth/lib/src/iam_signer.dart
@@ -62,10 +62,11 @@ class IAMSigner {
   IAMSigner(
     http.Client client, {
     String? serviceAccountEmail,
-    String endpoint = 'https://iamcredentials.$defaultUniverseDomain',
+    String? endpoint,
+    String universeDomain = defaultUniverseDomain,
   }) : _client = client,
        _serviceAccountEmail = serviceAccountEmail,
-       _endpoint = endpoint;
+       _endpoint = endpoint ?? 'https://iamcredentials.$universeDomain';
 
   /// Returns the service account email.
   ///
diff --git a/googleapis_auth/lib/src/impersonated_auth_client.dart b/googleapis_auth/lib/src/impersonated_auth_client.dart
index 6ea8d265e..c3295b8e1 100644
--- a/googleapis_auth/lib/src/impersonated_auth_client.dart
+++ b/googleapis_auth/lib/src/impersonated_auth_client.dart
@@ -169,15 +169,13 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
       'Failed to generate access token for impersonated service account.',
     );
 
-    final accessToken = responseJson['accessToken'] as String?;
-    final expireTime = responseJson['expireTime'] as String?;
-
-    if (accessToken == null || expireTime == null) {
-      throw ServerRequestFailedException(
+    final (accessToken, expireTime) = switch (responseJson) {
+      {'accessToken': final String t, 'expireTime': final String e} => (t, e),
+      _ => throw ServerRequestFailedException(
         'IAM generateAccessToken response missing required fields.',
         responseContent: responseJson,
-      );
-    }
+      ),
+    };
 
     // Parse RFC 3339 timestamp
     final expiry = DateTime.parse(expireTime);
@@ -203,7 +201,7 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
     final signer = IAMSigner(
       _sourceClient,
       serviceAccountEmail: _targetServiceAccount,
-      endpoint: _universeDomain,
+      universeDomain: _universeDomain,
     );
     return signer.sign(data);
   }
diff --git a/googleapis_auth/test/iam_signer_test.dart b/googleapis_auth/test/iam_signer_test.dart
index 1e067bf9c..4c5747908 100644
--- a/googleapis_auth/test/iam_signer_test.dart
+++ b/googleapis_auth/test/iam_signer_test.dart
@@ -161,6 +161,39 @@ void main() {
       expect(signature, equals(base64Encode([99, 88, 77])));
     });
 
+    test('sign with custom universe domain', () async {
+      final mockClient = MockClient(
+        expectAsync1((request) async {
+          if (request.method == 'POST') {
+            expect(
+              request.url.toString(),
+              equals(
+                'https://iamcredentials.example.com/v1/projects/-/serviceAccounts/custom%40example.iam.gserviceaccount.com:signBlob',
+              ),
+            );
+
+            return http.Response(
+              jsonEncode({
+                'signedBlob': base64Encode([11, 22, 33]),
+              }),
+              200,
+              headers: {'content-type': 'application/json'},
+            );
+          }
+          fail('Unexpected request: ${request.method} ${request.url}');
+        }),
+      );
+
+      final signer = IAMSigner(
+        mockClient,
+        serviceAccountEmail: 'custom@example.iam.gserviceaccount.com',
+        universeDomain: 'example.com',
+      );
+
+      final signature = await signer.sign([5, 6, 7]);
+      expect(signature, equals(base64Encode([11, 22, 33])));
+    });
+
     test('sign with custom endpoint', () async {
       final mockClient = MockClient(
         expectAsync1((request) async {

I was just fixing this. Nice!

demolaf and others added 2 commits February 10, 2026 19:27
Co-authored-by: Kevin Moore <kevmoo@users.noreply.github.com>
Copy link
Collaborator

@kevmoo kevmoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kevmoo kevmoo merged commit e131f9b into google:master Feb 10, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants