Skip to content

Commit 4772852

Browse files
committed
feat: add ImpersonatedAuthClient for service account impersonation via IAM Credentials API
1 parent 633cb07 commit 4772852

File tree

8 files changed

+731
-17
lines changed

8 files changed

+731
-17
lines changed

googleapis_auth/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
## 2.1.0-wip
22
- Add `serviceAccountCredentials` getter to AuthClient
33
- Added parsing for project_id and universe_domain properties for ServiceAccountCredentials
4-
- Add `sign()` method to `ServiceAccountCredentials` for RSA-SHA256 signing
4+
- 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: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import 'utils.dart';
1717
/// locally. Instead of signing locally, this class uses the IAM service to
1818
/// perform signing operations.
1919
///
20-
/// Does not close the [http.Client] passed to the constructor.
21-
///
2220
/// See: https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob
2321
///
2422
/// Example usage:
@@ -45,7 +43,7 @@ import 'utils.dart';
4543
class IAMSigner {
4644
final http.Client _client;
4745
final String? _serviceAccountEmail;
48-
final String _endpoint;
46+
final String _universeDomain;
4947

5048
String? _cachedEmail;
5149

@@ -57,15 +55,15 @@ class IAMSigner {
5755
/// [serviceAccountEmail] is the optional service account email to use for
5856
/// signing. If not provided, it will be fetched from the GCE metadata server.
5957
///
60-
/// [endpoint] specifies the IAM Credentials API endpoint.
61-
/// Defaults to `https://iamcredentials.googleapis.com`.
58+
/// [universeDomain] specifies the universe domain for constructing the IAM
59+
/// endpoint. Defaults to [defaultUniverseDomain].
6260
IAMSigner(
6361
http.Client client, {
6462
String? serviceAccountEmail,
65-
String endpoint = 'https://iamcredentials.$defaultUniverseDomain',
63+
String universeDomain = defaultUniverseDomain,
6664
}) : _client = client,
6765
_serviceAccountEmail = serviceAccountEmail,
68-
_endpoint = endpoint;
66+
_universeDomain = universeDomain;
6967

7068
/// Returns the service account email.
7169
///
@@ -110,7 +108,7 @@ class IAMSigner {
110108
final encodedEmail = Uri.encodeComponent(email);
111109

112110
final signBlobUrl = Uri.parse(
113-
'$_endpoint/v1/projects/-/serviceAccounts/$encodedEmail:signBlob',
111+
'https://iamcredentials.$_universeDomain/v1/projects/-/serviceAccounts/$encodedEmail:signBlob',
114112
);
115113

116114
final requestBody = jsonEncode({'payload': base64Encode(data)});
@@ -123,12 +121,15 @@ class IAMSigner {
123121
'Failed to sign blob via IAM.',
124122
);
125123

126-
return switch (responseJson) {
127-
{'signedBlob': final String signedBlob} => signedBlob,
128-
_ => throw ServerRequestFailedException(
124+
final signedBlob = responseJson['signedBlob'] as String?;
125+
126+
if (signedBlob == null) {
127+
throw ServerRequestFailedException(
129128
'IAM signBlob response missing signedBlob field.',
130129
responseContent: responseJson,
131-
),
132-
};
130+
);
131+
}
132+
133+
return signedBlob;
133134
}
134135
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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 as http.Client, 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 = responseJson['accessToken'] as String?;
173+
final expireTime = responseJson['expireTime'] as String?;
174+
175+
if (accessToken == null || expireTime == null) {
176+
throw ServerRequestFailedException(
177+
'IAM generateAccessToken response missing required fields.',
178+
responseContent: responseJson,
179+
);
180+
}
181+
182+
// Parse RFC 3339 timestamp
183+
final expiry = DateTime.parse(expireTime);
184+
185+
return AccessCredentials(
186+
AccessToken('Bearer', accessToken, expiry),
187+
null,
188+
_targetScopes,
189+
);
190+
}
191+
192+
/// Signs the given [data] using the IAM Credentials API.
193+
///
194+
/// This method calls the IAM Credentials API signBlob endpoint to sign data
195+
/// as the impersonated service account.
196+
///
197+
/// Returns the signature as a String
198+
///
199+
/// Throws [ServerRequestFailedException] if the signing operation fails.
200+
///
201+
/// See: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob
202+
Future<String> sign(List<int> data) {
203+
final signer = IAMSigner(
204+
_sourceClient,
205+
serviceAccountEmail: _targetServiceAccount,
206+
universeDomain: _universeDomain,
207+
);
208+
return signer.sign(data);
209+
}
210+
211+
@override
212+
Future<http.StreamedResponse> send(http.BaseRequest request) async {
213+
if (_credentials.accessToken.hasExpired) {
214+
final newCredentials = await generateAccessToken();
215+
notifyAboutNewCredentials(newCredentials);
216+
_credentials = newCredentials;
217+
_authClient = authenticatedClient(baseClient, _credentials);
218+
}
219+
220+
_authClient ??= authenticatedClient(baseClient, _credentials);
221+
return _authClient!.send(request);
222+
}
223+
}

googleapis_auth/lib/src/oauth2_flows/metadata_server.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class MetadataServerAuthorizationFlow extends BaseFlow {
2121
static const _serviceAccountUrlInfix =
2222
'computeMetadata/v1/instance/service-accounts';
2323

24+
// https://cloud.google.com/compute/docs/storing-retrieving-metadata#querying
25+
2426
final String email;
2527
final Uri _scopesUrl;
2628
final Uri _tokenUrl;

googleapis_auth/lib/src/utils.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ const maxExpectedTimeDiffInSeconds = 20;
1818

1919
// Metadata server constants
2020
const metadataFlavorHeader = {'Metadata-Flavor': 'Google'};
21-
// - https://cloud.google.com/compute/docs/storing-retrieving-metadata#querying
2221
const defaultMetadataHost = 'metadata.google.internal';
2322
const gceMetadataHostEnvVar = 'GCE_METADATA_HOST';
2423

googleapis_auth/test/iam_signer_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ void main() {
161161
expect(signature, equals(base64Encode([99, 88, 77])));
162162
});
163163

164-
test('sign with custom endpoint', () async {
164+
test('sign with custom universe domain', () async {
165165
final mockClient = MockClient(
166166
expectAsync1((request) async {
167167
if (request.method == 'POST') {
@@ -187,7 +187,7 @@ void main() {
187187
final signer = IAMSigner(
188188
mockClient,
189189
serviceAccountEmail: 'custom@example.iam.gserviceaccount.com',
190-
endpoint: 'https://iamcredentials.example.com',
190+
universeDomain: 'example.com',
191191
);
192192

193193
final signature = await signer.sign([5, 6, 7]);

0 commit comments

Comments
 (0)