Skip to content

Commit e131f9b

Browse files
authored
[googleapis_auth] feat: Add ImpersonatedAuthClient for service account impersonation (#711)
1 parent 633cb07 commit e131f9b

File tree

5 files changed

+718
-4
lines changed

5 files changed

+718
-4
lines changed

googleapis_auth/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- Added parsing for project_id and universe_domain properties for ServiceAccountCredentials
44
- Add `sign()` method to `ServiceAccountCredentials` for RSA-SHA256 signing
55
- Add `IAMSigner` class for signing via IAM Credentials API
6+
- Add `clientViaServiceAccountImpersonation()` function and `ImpersonatedAuthClient` class for service account impersonation via IAM Credentials API
67
- Require `meta: ^1.0.2`
78
- Require `sdk: ^3.9.0`
89
- Drop unneeded `args` dependency.

googleapis_auth/lib/auth_io.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'src/service_account_credentials.dart';
1717
import 'src/typedefs.dart';
1818

1919
export 'googleapis_auth.dart';
20+
export 'src/impersonated_auth_client.dart';
2021
export 'src/metadata_server_client.dart';
2122
export 'src/oauth2_flows/auth_code.dart'
2223
show obtainAccessCredentialsViaCodeExchange;

googleapis_auth/lib/src/iam_signer.dart

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,19 @@ class IAMSigner {
5757
/// [serviceAccountEmail] is the optional service account email to use for
5858
/// signing. If not provided, it will be fetched from the GCE metadata server.
5959
///
60-
/// [endpoint] specifies the IAM Credentials API endpoint.
61-
/// Defaults to `https://iamcredentials.googleapis.com`.
60+
/// [universeDomain] specifies the universe domain for constructing the IAM
61+
/// endpoint. Defaults to [defaultUniverseDomain] (googleapis.com).
62+
///
63+
/// [endpoint] specifies a custom IAM Credentials API endpoint URL.
64+
/// If provided, takes precedence over [universeDomain].
6265
IAMSigner(
6366
http.Client client, {
6467
String? serviceAccountEmail,
65-
String endpoint = 'https://iamcredentials.$defaultUniverseDomain',
68+
String? endpoint,
69+
String universeDomain = defaultUniverseDomain,
6670
}) : _client = client,
6771
_serviceAccountEmail = serviceAccountEmail,
68-
_endpoint = endpoint;
72+
_endpoint = endpoint ?? 'https://iamcredentials.$universeDomain';
6973

7074
/// Returns the service account email.
7175
///
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:convert';
6+
7+
import 'package:http/http.dart' as http;
8+
9+
import 'auth_functions.dart';
10+
import 'auth_http_utils.dart';
11+
import 'iam_signer.dart';
12+
import 'service_account_credentials.dart';
13+
import 'utils.dart';
14+
15+
/// Obtains oauth2 credentials by impersonating a service account via IAM API.
16+
///
17+
/// Uses a source credential to call the IAM Credentials API to obtain access
18+
/// tokens and sign data as a target service account.
19+
///
20+
/// See: https://cloud.google.com/iam/docs/create-short-lived-credentials-direct
21+
///
22+
/// {@macro googleapis_auth_client_for_creds}
23+
///
24+
/// The source client must have the `roles/iam.serviceAccountTokenCreator` role
25+
/// on the target service account.
26+
///
27+
/// Example:
28+
/// ```dart
29+
/// // Get source client
30+
/// final sourceClient = await clientViaServiceAccount(credentials, scopes);
31+
///
32+
/// // Create impersonated client
33+
/// final impersonated = await clientViaServiceAccountImpersonation(
34+
/// sourceClient: sourceClient,
35+
/// targetServiceAccount: 'target@project.iam.gserviceaccount.com',
36+
/// targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
37+
/// );
38+
///
39+
/// // Make authenticated requests
40+
/// await impersonated.get(Uri.parse('https://...'));
41+
///
42+
/// // Sign data as the impersonated account
43+
/// final signature = await impersonated.sign([1, 2, 3, 4, 5]);
44+
///
45+
/// // Explicitly generate a new access token
46+
/// final token = await impersonated.generateAccessToken();
47+
/// ```
48+
Future<ImpersonatedAuthClient> clientViaServiceAccountImpersonation({
49+
required AuthClient sourceClient,
50+
required String targetServiceAccount,
51+
required List<String> targetScopes,
52+
List<String>? delegates,
53+
String universeDomain = defaultUniverseDomain,
54+
Duration lifetime = const Duration(hours: 1),
55+
}) async {
56+
final impersonatedClient = ImpersonatedAuthClient(
57+
sourceClient: sourceClient,
58+
targetServiceAccount: targetServiceAccount,
59+
targetScopes: targetScopes,
60+
delegates: delegates,
61+
universeDomain: universeDomain,
62+
lifetime: lifetime,
63+
);
64+
65+
// Generate initial credentials
66+
try {
67+
final credentials = await impersonatedClient.generateAccessToken();
68+
impersonatedClient._credentials = credentials;
69+
return impersonatedClient;
70+
} catch (e) {
71+
impersonatedClient.close();
72+
rethrow;
73+
}
74+
}
75+
76+
/// An authenticated HTTP client that impersonates a service account.
77+
///
78+
/// This client allows a source credential to act as a different service account
79+
/// through Google's IAM Credentials API. It supports:
80+
/// - Auto-refreshing access tokens
81+
/// - Signing data as the impersonated account
82+
/// - Delegation chains for multi-hop impersonation
83+
/// - Custom universe domains
84+
class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
85+
final AuthClient _sourceClient;
86+
final String _targetServiceAccount;
87+
final List<String> _targetScopes;
88+
final List<String>? _delegates;
89+
final String _universeDomain;
90+
final Duration _lifetime;
91+
92+
AccessCredentials _credentials;
93+
http.Client? _authClient;
94+
95+
/// Creates an [ImpersonatedAuthClient] instance.
96+
///
97+
/// [sourceClient] is the authenticated client used to make IAM API requests.
98+
///
99+
/// [targetServiceAccount] is the email of the service account to impersonate.
100+
///
101+
/// [targetScopes] are the OAuth2 scopes to request for the impersonated
102+
/// service account.
103+
///
104+
/// [delegates] is an optional list of service accounts in a delegation chain.
105+
/// Each service account must be granted `roles/iam.serviceAccountTokenCreator`
106+
/// on the next service account in the chain.
107+
///
108+
/// [universeDomain] specifies the universe domain for constructing the IAM
109+
/// endpoint. Defaults to [defaultUniverseDomain].
110+
///
111+
/// [lifetime] specifies how long the access token should be valid. Defaults
112+
/// to 3600 seconds (1 hour). Maximum is 43200 seconds (12 hours).
113+
ImpersonatedAuthClient({
114+
required AuthClient sourceClient,
115+
required String targetServiceAccount,
116+
required List<String> targetScopes,
117+
List<String>? delegates,
118+
String universeDomain = defaultUniverseDomain,
119+
Duration lifetime = const Duration(hours: 1),
120+
}) : _sourceClient = sourceClient,
121+
_targetServiceAccount = targetServiceAccount,
122+
_targetScopes = List.unmodifiable(targetScopes),
123+
_delegates = delegates != null ? List.unmodifiable(delegates) : null,
124+
_universeDomain = universeDomain,
125+
_lifetime = lifetime,
126+
_credentials = AccessCredentials(
127+
AccessToken('Bearer', '', DateTime.now().toUtc()),
128+
null,
129+
targetScopes,
130+
),
131+
super(sourceClient, closeUnderlyingClient: false);
132+
133+
/// The email of the target service account being impersonated.
134+
String get targetServiceAccount => _targetServiceAccount;
135+
136+
@override
137+
AccessCredentials get credentials => _credentials;
138+
139+
@override
140+
ServiceAccountCredentials? get serviceAccountCredentials => null;
141+
142+
/// Generates a new access token for the impersonated service account.
143+
///
144+
/// This method calls the IAM Credentials API generateAccessToken endpoint
145+
/// to obtain a new access token. The token will be valid for the duration
146+
/// specified when the client was created.
147+
///
148+
/// Returns [AccessCredentials] containing the new access token.
149+
///
150+
/// Throws [ServerRequestFailedException] if the request fails.
151+
Future<AccessCredentials> generateAccessToken() async {
152+
final encodedEmail = Uri.encodeComponent(_targetServiceAccount);
153+
final tokenUrl = Uri.parse(
154+
'https://iamcredentials.$_universeDomain/v1/projects/-/serviceAccounts/$encodedEmail:generateAccessToken',
155+
);
156+
157+
final requestBody = jsonEncode({
158+
'scope': _targetScopes,
159+
if (_delegates != null) 'delegates': _delegates,
160+
'lifetime': '${_lifetime.inSeconds}s',
161+
});
162+
163+
final request = http.Request('POST', tokenUrl)
164+
..headers['Content-Type'] = 'application/json'
165+
..body = requestBody;
166+
167+
final responseJson = await _sourceClient.requestJson(
168+
request,
169+
'Failed to generate access token for impersonated service account.',
170+
);
171+
172+
final (accessToken, expireTime) = switch (responseJson) {
173+
{'accessToken': final String t, 'expireTime': final String e} => (t, e),
174+
_ => throw ServerRequestFailedException(
175+
'IAM generateAccessToken response missing required fields.',
176+
responseContent: responseJson,
177+
),
178+
};
179+
180+
// Parse RFC 3339 timestamp
181+
final expiry = DateTime.parse(expireTime);
182+
183+
return AccessCredentials(
184+
AccessToken('Bearer', accessToken, expiry),
185+
null,
186+
_targetScopes,
187+
);
188+
}
189+
190+
/// Signs the given [data] using the IAM Credentials API.
191+
///
192+
/// This method calls the IAM Credentials API signBlob endpoint to sign data
193+
/// as the impersonated service account.
194+
///
195+
/// Returns the signature as a String
196+
///
197+
/// Throws [ServerRequestFailedException] if the signing operation fails.
198+
///
199+
/// See: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob
200+
Future<String> sign(List<int> data) {
201+
final signer = IAMSigner(
202+
_sourceClient,
203+
serviceAccountEmail: _targetServiceAccount,
204+
universeDomain: _universeDomain,
205+
);
206+
return signer.sign(data);
207+
}
208+
209+
@override
210+
Future<http.StreamedResponse> send(http.BaseRequest request) async {
211+
if (_credentials.accessToken.hasExpired) {
212+
final newCredentials = await generateAccessToken();
213+
notifyAboutNewCredentials(newCredentials);
214+
_credentials = newCredentials;
215+
_authClient = authenticatedClient(baseClient, _credentials);
216+
}
217+
218+
_authClient ??= authenticatedClient(baseClient, _credentials);
219+
return _authClient!.send(request);
220+
}
221+
}

0 commit comments

Comments
 (0)