Skip to content

Commit 9a77bb4

Browse files
authored
[googleapis_auth] feat: Add smart signing extension method to AuthClient (#712)
1 parent e131f9b commit 9a77bb4

File tree

4 files changed

+366
-0
lines changed

4 files changed

+366
-0
lines changed

googleapis_auth/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- Add `sign()` method to `ServiceAccountCredentials` for RSA-SHA256 signing
55
- Add `IAMSigner` class for signing via IAM Credentials API
66
- Add `clientViaServiceAccountImpersonation()` function and `ImpersonatedAuthClient` class for service account impersonation via IAM Credentials API
7+
- Add `AuthClientSigningExtension` extension on `AuthClient` providing a universal `sign()` method that works across all auth contexts (service accounts, ADC, impersonated credentials)
78
- Require `meta: ^1.0.2`
89
- Require `sdk: ^3.9.0`
910
- Drop unneeded `args` dependency.

googleapis_auth/lib/googleapis_auth.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
library;
2727

2828
export 'src/auth_client.dart';
29+
export 'src/auth_client_signing_extension.dart';
2930
export 'src/auth_endpoints.dart';
3031
export 'src/auth_functions.dart';
3132
export 'src/client_id.dart';
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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 'auth_client.dart';
8+
import 'iam_signer.dart';
9+
import 'impersonated_auth_client.dart';
10+
import 'utils.dart';
11+
12+
/// Extension providing smart signing capabilities for [AuthClient].
13+
///
14+
/// This extension adds a universal [sign] method that automatically selects
15+
/// the appropriate signing strategy based on the authentication context:
16+
///
17+
/// 1. **ImpersonatedAuthClient**: Uses IAM signBlob with the target principal
18+
/// 2. **Service account credentials**: Uses local RSA-SHA256 signing
19+
/// 3. **Other auth clients** (ADC on GCE/Cloud Run): Uses IAM signBlob with
20+
/// the default service account from metadata server
21+
///
22+
/// Example usage:
23+
/// ```dart
24+
/// // Works with service account credentials
25+
/// final client = await clientViaServiceAccount(credentials, scopes);
26+
/// final signature = await client.sign(utf8.encode('data to sign'));
27+
///
28+
/// // Works with ADC on GCE/Cloud Run
29+
/// final client = await clientViaApplicationDefaultCredentials(scopes: scopes);
30+
/// final signature = await client.sign(utf8.encode('data to sign'));
31+
///
32+
/// // Works with impersonated credentials
33+
/// final client = await clientViaServiceAccountImpersonation(
34+
/// sourceClient: sourceClient,
35+
/// targetServiceAccount: 'target@project.iam.gserviceaccount.com',
36+
/// targetScopes: scopes,
37+
/// );
38+
/// final signature = await client.sign(utf8.encode('data to sign'));
39+
/// ```
40+
extension AuthClientSigningExtension on AuthClient {
41+
/// Signs some bytes using the credentials from this auth client.
42+
///
43+
/// The signing behavior depends on the auth client type:
44+
/// - [ImpersonatedAuthClient]: Uses IAM signBlob API to sign using the
45+
/// target principal.
46+
/// - Auth clients with service account credentials: Signs locally using
47+
/// RSA-SHA256.
48+
/// - Other auth clients: Uses IAM signBlob API with the default service
49+
/// account.
50+
///
51+
/// [data] is the bytes to be signed.
52+
///
53+
/// [endpoint] is an optional custom IAM Credentials API endpoint. This is
54+
/// useful when working with different universe domains. If not provided,
55+
/// the endpoint is automatically determined from the credential's universe
56+
/// domain (e.g., `https://iamcredentials.googleapis.com` for the default
57+
/// universe, or a custom universe domain from the service account JSON).
58+
///
59+
/// Returns the signature as a String (base64-encoded).
60+
///
61+
/// Example:
62+
/// ```dart
63+
/// import 'dart:convert';
64+
///
65+
/// final client = await clientViaServiceAccount(credentials, scopes);
66+
/// final data = utf8.encode('data to sign');
67+
/// final signature = await client.sign(data);
68+
/// print('Signature (base64): $signature');
69+
/// ```
70+
Future<String> sign(List<int> data, {String? endpoint}) async {
71+
// Check if this is an impersonated client
72+
if (this is ImpersonatedAuthClient) {
73+
final impersonated = this as ImpersonatedAuthClient;
74+
return impersonated.sign(data);
75+
}
76+
77+
// Check if we have service account credentials for local signing
78+
final serviceAccountCreds = serviceAccountCredentials;
79+
80+
if (serviceAccountCreds != null) {
81+
// Use local signing with service account credentials
82+
return base64Encode(serviceAccountCreds.sign(data));
83+
}
84+
85+
// If we're NOT using local signing, use IAM API signing
86+
final universeDomain =
87+
serviceAccountCreds?.universeDomain ?? defaultUniverseDomain;
88+
endpoint ??= 'https://iamcredentials.$universeDomain';
89+
return IAMSigner(this, endpoint: endpoint).sign(data);
90+
}
91+
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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:googleapis_auth/src/auth_client_signing_extension.dart';
8+
import 'package:googleapis_auth/src/auth_functions.dart';
9+
import 'package:googleapis_auth/src/impersonated_auth_client.dart';
10+
import 'package:googleapis_auth/src/service_account_client.dart';
11+
import 'package:googleapis_auth/src/service_account_credentials.dart';
12+
import 'package:http/http.dart' as http;
13+
import 'package:test/test.dart';
14+
15+
import 'test_utils.dart';
16+
17+
void main() {
18+
group('auth-client-signing-extension', () {
19+
final dataToSign = utf8.encode('data to sign');
20+
21+
test('sign with service account credentials uses local signing', () async {
22+
// Create service account credentials with private key
23+
final credentials = ServiceAccountCredentials.fromJson({
24+
'private_key_id': '1',
25+
'private_key': testPrivateKeyString,
26+
'client_email': 'test@test.iam.gserviceaccount.com',
27+
'client_id': 'test',
28+
'type': 'service_account',
29+
});
30+
31+
final client = await clientViaServiceAccount(
32+
credentials,
33+
['https://www.googleapis.com/auth/cloud-platform'],
34+
baseClient: mockClient(
35+
(request) async => http.Response(
36+
jsonEncode({
37+
'access_token': 'test-token',
38+
'token_type': 'Bearer',
39+
'expires_in': 3600,
40+
}),
41+
200,
42+
headers: jsonContentType,
43+
),
44+
expectClose: false,
45+
),
46+
);
47+
48+
// Should use local RSA signing, no HTTP requests to IAM API
49+
final signature = await client.sign(dataToSign);
50+
51+
expect(signature, isNotEmpty);
52+
expect(
53+
signature.length,
54+
equals(344),
55+
); // RSA-2048 signature base64-encoded length
56+
57+
client.close();
58+
});
59+
60+
test('sign with impersonated client uses IAM API', () async {
61+
var requestCount = 0;
62+
final baseClient = mockClient(
63+
expectAsync1((request) async {
64+
requestCount++;
65+
66+
if (requestCount == 1) {
67+
// Initial generateAccessToken for impersonated client
68+
final expireTime = DateTime.now().toUtc().add(
69+
const Duration(hours: 1),
70+
);
71+
return http.Response(
72+
jsonEncode({
73+
'accessToken': 'impersonated-token',
74+
'expireTime': expireTime.toIso8601String(),
75+
}),
76+
200,
77+
headers: jsonContentType,
78+
);
79+
} else {
80+
// signBlob request via ImpersonatedAuthClient.sign()
81+
expect(request.method, equals('POST'));
82+
expect(request.url.toString(), contains(':signBlob'));
83+
84+
return http.Response(
85+
jsonEncode({
86+
'signedBlob': base64Encode([1, 2, 3, 4, 5]),
87+
}),
88+
200,
89+
headers: jsonContentType,
90+
);
91+
}
92+
}, count: 2),
93+
expectClose: false,
94+
);
95+
96+
final sourceCredentials = AccessCredentials(
97+
AccessToken(
98+
'Bearer',
99+
'source-token',
100+
DateTime.now().toUtc().add(const Duration(hours: 1)),
101+
),
102+
null,
103+
[],
104+
);
105+
final sourceClient = authenticatedClient(baseClient, sourceCredentials);
106+
107+
final impersonated = await clientViaServiceAccountImpersonation(
108+
sourceClient: sourceClient,
109+
targetServiceAccount: 'target@project.iam.gserviceaccount.com',
110+
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
111+
);
112+
113+
final signature = await impersonated.sign(dataToSign);
114+
115+
expect(signature, equals(base64Encode([1, 2, 3, 4, 5])));
116+
117+
impersonated.close();
118+
});
119+
120+
test('explicitly calling extension on impersonated client delegates to '
121+
'instance method', () async {
122+
var requestCount = 0;
123+
final baseClient = mockClient(
124+
expectAsync1((request) async {
125+
requestCount++;
126+
127+
if (requestCount == 1) {
128+
// Initial generateAccessToken for impersonated client
129+
final expireTime = DateTime.now().toUtc().add(
130+
const Duration(hours: 1),
131+
);
132+
return http.Response(
133+
jsonEncode({
134+
'accessToken': 'impersonated-token',
135+
'expireTime': expireTime.toIso8601String(),
136+
}),
137+
200,
138+
headers: jsonContentType,
139+
);
140+
} else {
141+
// signBlob request via ImpersonatedAuthClient.sign()
142+
expect(request.method, equals('POST'));
143+
expect(request.url.toString(), contains(':signBlob'));
144+
145+
return http.Response(
146+
jsonEncode({
147+
'signedBlob': base64Encode([5, 6, 7, 8]),
148+
}),
149+
200,
150+
headers: jsonContentType,
151+
);
152+
}
153+
}, count: 2),
154+
expectClose: false,
155+
);
156+
157+
final sourceCredentials = AccessCredentials(
158+
AccessToken(
159+
'Bearer',
160+
'source-token',
161+
DateTime.now().toUtc().add(const Duration(hours: 1)),
162+
),
163+
null,
164+
[],
165+
);
166+
final sourceClient = authenticatedClient(baseClient, sourceCredentials);
167+
168+
final impersonated = await clientViaServiceAccountImpersonation(
169+
sourceClient: sourceClient,
170+
targetServiceAccount: 'target@project.iam.gserviceaccount.com',
171+
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
172+
);
173+
174+
// Explicitly call the extension method with endpoint parameter
175+
// The endpoint is ignored since ImpersonatedAuthClient uses its
176+
// configured universe domain
177+
final signature = await AuthClientSigningExtension(
178+
impersonated,
179+
).sign(dataToSign, endpoint: 'https://iamcredentials.example.com');
180+
181+
expect(signature, equals(base64Encode([5, 6, 7, 8])));
182+
183+
impersonated.close();
184+
});
185+
186+
test('sign without service account credentials uses IAM API', () async {
187+
final baseClient = mockClient(
188+
expectAsync1((request) async {
189+
if (request.url.path.contains('/email')) {
190+
// Metadata server request for service account email
191+
return http.Response('test@test.iam.gserviceaccount.com', 200);
192+
} else {
193+
// IAM signBlob request
194+
expect(request.method, equals('POST'));
195+
expect(request.url.toString(), contains(':signBlob'));
196+
197+
final body = jsonDecode(request.body) as Map<String, dynamic>;
198+
expect(body['payload'], equals(base64Encode(dataToSign)));
199+
200+
return http.Response(
201+
jsonEncode({
202+
'signedBlob': base64Encode([10, 20, 30]),
203+
}),
204+
200,
205+
headers: jsonContentType,
206+
);
207+
}
208+
}, count: 2),
209+
expectClose: false,
210+
);
211+
212+
final credentials = AccessCredentials(
213+
AccessToken(
214+
'Bearer',
215+
'token',
216+
DateTime.now().toUtc().add(const Duration(hours: 1)),
217+
),
218+
null,
219+
[],
220+
);
221+
final client = authenticatedClient(baseClient, credentials);
222+
223+
final signature = await client.sign(dataToSign);
224+
225+
expect(signature, equals(base64Encode([10, 20, 30])));
226+
227+
client.close();
228+
}, testOn: 'vm');
229+
230+
test('sign with custom endpoint extracts universe domain', () async {
231+
final baseClient = mockClient(
232+
expectAsync1((request) async {
233+
if (request.url.path.contains('/email')) {
234+
// Metadata server request for service account email
235+
return http.Response('test@test.iam.gserviceaccount.com', 200);
236+
} else {
237+
// IAM signBlob request - verify custom universe domain is used
238+
expect(request.url.host, equals('iamcredentials.example.com'));
239+
240+
return http.Response(
241+
jsonEncode({
242+
'signedBlob': base64Encode([5, 6, 7]),
243+
}),
244+
200,
245+
headers: jsonContentType,
246+
);
247+
}
248+
}, count: 2),
249+
expectClose: false,
250+
);
251+
252+
final credentials = AccessCredentials(
253+
AccessToken(
254+
'Bearer',
255+
'token',
256+
DateTime.now().toUtc().add(const Duration(hours: 1)),
257+
),
258+
null,
259+
[],
260+
);
261+
final client = authenticatedClient(baseClient, credentials);
262+
263+
final signature = await client.sign(
264+
dataToSign,
265+
endpoint: 'https://iamcredentials.example.com',
266+
);
267+
268+
expect(signature, equals(base64Encode([5, 6, 7])));
269+
270+
client.close();
271+
}, testOn: 'vm');
272+
});
273+
}

0 commit comments

Comments
 (0)