Skip to content

Commit 84de376

Browse files
committed
Support optional baseClient in ImpersonatedAuthClient, and safely extract quotaProject from ADC json
1 parent 7e8a932 commit 84de376

File tree

5 files changed

+139
-2
lines changed

5 files changed

+139
-2
lines changed

googleapis_auth/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
- Added `quotaProject` support to existing credentials classes
44
(`ServiceAccountCredentials`, `ClientViaServiceAccount`, `ClientFromFlow`).
5+
- `clientViaApplicationDefaultCredentials` now extracts `quotaProject` correctly
6+
from service account credentials JSON.
7+
- `clientViaServiceAccountImpersonation` and `ImpersonatedAuthClient` now accept
8+
an optional `baseClient`.
59

610
## 2.1.0
711

googleapis_auth/lib/src/adc_utils.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
3131
);
3232
}
3333

34+
if (credentials is! Map) {
35+
throw Exception(
36+
'Failed to parse JSON from credentials file from $fileSource',
37+
);
38+
}
39+
final quotaProject = credentials['quota_project_id'] as String?;
40+
3441
if (credentials case {
3542
'type': 'authorized_user',
3643
'client_id': final String clientIdString,
@@ -52,12 +59,13 @@ Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
5259
),
5360
baseClient,
5461
),
55-
quotaProject: credentials['quota_project_id'] as String?,
62+
quotaProject: quotaProject,
5663
);
5764
}
5865
return await clientViaServiceAccount(
5966
ServiceAccountCredentials.fromJson(credentials),
6067
scopes,
6168
baseClient: baseClient,
69+
quotaProject: quotaProject,
6270
);
6371
}

googleapis_auth/lib/src/impersonated_auth_client.dart

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,18 @@ import 'utils.dart';
4646
/// // Explicitly generate a new access token
4747
/// final token = await impersonated.generateAccessToken();
4848
/// ```
49+
///
50+
/// [baseClient] is an optional [http.Client] that will be used for
51+
/// the returned client's authenticated requests. If not provided, a new
52+
/// [http.Client] will be instantiated.
4953
Future<ImpersonatedAuthClient> clientViaServiceAccountImpersonation({
5054
required AuthClient sourceClient,
5155
required String targetServiceAccount,
5256
required List<String> targetScopes,
5357
List<String>? delegates,
5458
String universeDomain = defaultUniverseDomain,
5559
Duration lifetime = const Duration(hours: 1),
60+
http.Client? baseClient,
5661
}) async {
5762
final impersonatedClient = ImpersonatedAuthClient(
5863
sourceClient: sourceClient,
@@ -61,6 +66,7 @@ Future<ImpersonatedAuthClient> clientViaServiceAccountImpersonation({
6166
delegates: delegates,
6267
universeDomain: universeDomain,
6368
lifetime: lifetime,
69+
baseClient: baseClient,
6470
);
6571

6672
// Generate initial credentials
@@ -113,13 +119,18 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
113119
///
114120
/// [lifetime] specifies how long the access token should be valid. Defaults
115121
/// to 3600 seconds (1 hour). Maximum is 43200 seconds (12 hours).
122+
///
123+
/// [baseClient] is an optional [http.Client] that will be used for
124+
/// the returned client's authenticated requests. If not provided, a new
125+
/// [http.Client] will be instantiated.
116126
ImpersonatedAuthClient({
117127
required AuthClient sourceClient,
118128
required this.targetServiceAccount,
119129
required List<String> targetScopes,
120130
List<String>? delegates,
121131
String universeDomain = defaultUniverseDomain,
122132
Duration lifetime = const Duration(hours: 1),
133+
http.Client? baseClient,
123134
}) : _sourceClient = sourceClient,
124135
_targetScopes = List.unmodifiable(targetScopes),
125136
_delegates = delegates != null ? List.unmodifiable(delegates) : null,
@@ -130,7 +141,10 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
130141
null,
131142
targetScopes,
132143
),
133-
super(sourceClient, closeUnderlyingClient: false);
144+
super(
145+
baseClient ?? http.Client(),
146+
closeUnderlyingClient: baseClient == null,
147+
);
134148

135149
@override
136150
AccessCredentials get credentials => _credentials;

googleapis_auth/test/adc_test.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,63 @@ void main() {
137137

138138
c.close();
139139
});
140+
141+
test('service_account credentials with quota_project_id', () async {
142+
await d
143+
.file(
144+
'creds.json',
145+
json.encode({
146+
'private_key_id': 'id',
147+
'private_key': testPrivateKeyString,
148+
'client_email': 'test@example.com',
149+
'client_id': 'client_id',
150+
'type': 'service_account',
151+
'quota_project_id': 'test-quota-project',
152+
}),
153+
)
154+
.create();
155+
156+
final c = await fromApplicationsCredentialsFile(
157+
File(d.path('creds.json')),
158+
'test-credentials-file',
159+
['https://www.googleapis.com/auth/cloud-platform'],
160+
mockClient(
161+
expectAsync1((Request request) async {
162+
final url = request.url;
163+
if (url == googleOauth2TokenEndpoint) {
164+
expect(request.method, 'POST');
165+
return Response(
166+
jsonEncode({
167+
'access_token': 'atoken',
168+
'token_type': 'Bearer',
169+
'expires_in': 3600,
170+
}),
171+
200,
172+
headers: jsonContentType,
173+
);
174+
}
175+
if (url.toString() ==
176+
'https://storage.googleapis.com/b/bucket/o/obj') {
177+
expect(request.method, 'GET');
178+
expect(
179+
request.headers,
180+
containsPair('X-Goog-User-Project', 'test-quota-project'),
181+
);
182+
return Response('hello world', 200);
183+
}
184+
return Response('bad', 404);
185+
}, count: 2),
186+
expectClose: false,
187+
),
188+
);
189+
expect(c.credentials.accessToken.data, 'atoken');
190+
191+
final r = await c.get(
192+
Uri.https('storage.googleapis.com', '/b/bucket/o/obj'),
193+
);
194+
expect(r.statusCode, 200);
195+
expect(r.body, 'hello world');
196+
197+
c.close();
198+
});
140199
}

googleapis_auth/test/impersonated_auth_client_test.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,4 +436,56 @@ void main() {
436436

437437
impersonated.close();
438438
});
439+
440+
test('uses provided baseClient for authenticated requests', () async {
441+
var generateTokenCalled = false;
442+
final sourceBaseClient = mockClient((request) async {
443+
generateTokenCalled = true;
444+
final expireTime = DateTime.now().toUtc().add(const Duration(hours: 1));
445+
return http.Response(
446+
jsonEncode({
447+
'accessToken': 'impersonated-token',
448+
'expireTime': expireTime.toIso8601String(),
449+
}),
450+
200,
451+
headers: jsonContentType,
452+
);
453+
}, expectClose: false);
454+
455+
final sourceCredentials = AccessCredentials(
456+
AccessToken(
457+
'Bearer',
458+
'source-token',
459+
DateTime.now().toUtc().add(const Duration(hours: 1)),
460+
),
461+
null,
462+
[],
463+
);
464+
final sourceClient = authenticatedClient(
465+
sourceBaseClient,
466+
sourceCredentials,
467+
);
468+
469+
var authenticatedRequestCalled = false;
470+
final customBaseClient = mockClient((request) async {
471+
authenticatedRequestCalled = true;
472+
expect(request.headers['Authorization'], 'Bearer impersonated-token');
473+
return http.Response('ok', 200);
474+
}, expectClose: false);
475+
476+
final impersonated = await clientViaServiceAccountImpersonation(
477+
sourceClient: sourceClient,
478+
targetServiceAccount: 'target@project.iam.gserviceaccount.com',
479+
targetScopes: ['scope1'],
480+
baseClient: customBaseClient,
481+
);
482+
483+
expect(generateTokenCalled, isTrue);
484+
485+
final response = await impersonated.get(Uri.parse('https://example.com'));
486+
expect(response.statusCode, 200);
487+
expect(authenticatedRequestCalled, isTrue);
488+
489+
impersonated.close();
490+
});
439491
}

0 commit comments

Comments
 (0)