Skip to content

Commit eb39316

Browse files
authored
Refactor auth logic and consolidate metadata server handling (#715)
This commit refactors the googleapis_auth package to leverage package:google_cloud for consolidated metadata server handling and modernizes the codebase using Dart 3 features. Key changes: * Metadata Server Consolidation: - Removed internal metadata server constants and logic from lib/src/utils.dart. - Updated IAMSigner and MetadataServerAuthorizationFlow to use getMetadataValue (caching) and fetchMetadataValue (non-caching) from package:google_cloud/general.dart. - Added refresh support to MetadataServerAuthorizationFlow.run() and IAMSigner.getServiceAccountEmail(). * IAM & Impersonation Improvements: - Updated IAMSigner.sign() and ImpersonatedAuthClient.sign() to return a record ({String signedBlob, String keyId}), providing access to the key ID used for signing. - Added support for service account delegation chains in IAMSigner.sign() via the delegates parameter. - Made IAMSigner properties serviceAccountEmail and endpoint public for better visibility and customization. * Modernized Dart Code: - Extensively adopted Dart 3 features including pattern matching (switch expressions, if-case statements), records, and null-aware map entries (? syntax). - Refactored parseAccessToken and Application Default Credentials (ADC) parsing to be more concise and readable using pattern matching. * Client Extensions: - Updated ClientExtensions.requestJson to a more flexible signature, allowing direct specification of HTTP method, URL, headers, and body. - Added getServiceAccountEmail() to AuthClientSigningExtension. * Dependencies & Requirements: - Added dependency on package:google_cloud: ^0.3.0. - Updated SDK requirement to ^3.9.0. - Updated package:meta to ^1.15.0. - Added package:test_descriptor as a dev dependency to simplify test file management. * Testing: - Refactored IAMSigner and ADC tests to use package:test_descriptor, replacing manual temporary directory management. - Updated tests to reflect the new record return types for signing and the unified metadata caching behavior.
1 parent 59448c1 commit eb39316

16 files changed

+675
-787
lines changed

googleapis_auth/CHANGELOG.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1-
## 2.1.0-beta.1
2-
- Add `serviceAccountCredentials` getter to AuthClient
3-
- Added parsing for project_id and universe_domain properties for ServiceAccountCredentials
4-
- Add `sign()` method to `ServiceAccountCredentials` for RSA-SHA256 signing
5-
- Add `IAMSigner` class for signing via IAM Credentials API
6-
- 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)
8-
- Require `meta: ^1.0.2`
9-
- Require `sdk: ^3.9.0`
10-
- Drop unneeded `args` dependency.
1+
## 2.1.0-wip
2+
3+
- `AuthClient`
4+
- Added `serviceAccountCredentials` getter.
5+
- Added `sign()` method via `AuthClientSigningExtension`.
6+
- `ServiceAccountCredentials`
7+
- Added parsing for `projectId` and `universeDomain` properties.
8+
- Added `sign()` method for RSA-SHA256 signing.
9+
- `IAMSigner`
10+
- Now uses the unified metadata cache from `package:google_cloud`.
11+
- `MetadataServerAuthorizationFlow`
12+
- Now uses `getMetadataValue` (caching) and `fetchMetadataValue`
13+
(non-caching) from `package:google_cloud`.
14+
- Added `refresh` support to `run()`.
15+
- Added `clientViaServiceAccountImpersonation()` function and
16+
`ImpersonatedAuthClient` class for service account impersonation via IAM
17+
Credentials API.
1118
- Export `RSAPrivateKey` which is exposed by `ServiceAccountCredentials`.
19+
- Modernized code using pattern matching and switch expressions.
20+
- Require `google_cloud: ^0.3.0`.
21+
- Require `meta: ^1.15.0`.
22+
- Require `sdk: ^3.9.0`.
23+
- Drop unneeded `args` dependency.
1224

1325
## 2.0.0
1426

googleapis_auth/lib/src/adc_utils.dart

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

34-
if (credentials is Map && credentials['type'] == 'authorized_user') {
35-
final clientId = ClientId(
36-
credentials['client_id'] as String,
37-
credentials['client_secret'] as String?,
38-
);
34+
if (credentials case {
35+
'type': 'authorized_user',
36+
'client_id': final String clientIdString,
37+
'client_secret': final String? clientSecret,
38+
'refresh_token': final String? refreshToken,
39+
}) {
40+
final clientId = ClientId(clientIdString, clientSecret);
3941
return AutoRefreshingClient(
4042
baseClient,
4143
const GoogleAuthEndpoints(),
@@ -45,7 +47,7 @@ Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
4547
AccessCredentials(
4648
// Hack: Create empty credentials that have expired.
4749
AccessToken('Bearer', '', DateTime(0).toUtc()),
48-
credentials['refresh_token'] as String?,
50+
refreshToken,
4951
scopes,
5052
),
5153
baseClient,

googleapis_auth/lib/src/auth_client_signing_extension.dart

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
/// @docImport 'auth_client.dart';
6+
library;
7+
58
import 'dart:convert';
69

7-
import 'auth_client.dart';
810
import 'iam_signer.dart';
911
import 'impersonated_auth_client.dart';
12+
import 'service_account_credentials.dart';
1013
import 'utils.dart';
1114

1215
/// Extension providing smart signing capabilities for [AuthClient].
@@ -38,6 +41,22 @@ import 'utils.dart';
3841
/// final signature = await client.sign(utf8.encode('data to sign'));
3942
/// ```
4043
extension AuthClientSigningExtension on AuthClient {
44+
/// Returns the service account email associated with this client.
45+
///
46+
/// If the client was created with explicit [ServiceAccountCredentials],
47+
/// returns the email from those credentials.
48+
///
49+
/// Otherwise, queries the GCE metadata server to retrieve the default
50+
/// service account email.
51+
///
52+
/// The result is cached for the lifetime of the Dart process by the
53+
/// underlying [IAMSigner].
54+
///
55+
/// If [refresh] is `true`, the cache is cleared and the value is re-computed.
56+
Future<String> getServiceAccountEmail({bool refresh = false}) async =>
57+
serviceAccountCredentials?.email ??
58+
await IAMSigner(this).getServiceAccountEmail(refresh: refresh);
59+
4160
/// Signs some bytes using the credentials from this auth client.
4261
///
4362
/// The signing behavior depends on the auth client type:
@@ -65,13 +84,13 @@ extension AuthClientSigningExtension on AuthClient {
6584
/// final client = await clientViaServiceAccount(credentials, scopes);
6685
/// final data = utf8.encode('data to sign');
6786
/// final signature = await client.sign(data);
68-
/// print('Signature (base64): $signature');
87+
/// print('Signature (base64): ${signature.signedBlob}');
6988
/// ```
7089
Future<String> sign(List<int> data, {String? endpoint}) async {
7190
// Check if this is an impersonated client
7291
if (this is ImpersonatedAuthClient) {
7392
final impersonated = this as ImpersonatedAuthClient;
74-
return impersonated.sign(data);
93+
return (await impersonated.sign(data)).signedBlob;
7594
}
7695

7796
// Check if we have service account credentials for local signing
@@ -86,6 +105,6 @@ extension AuthClientSigningExtension on AuthClient {
86105
final universeDomain =
87106
serviceAccountCreds?.universeDomain ?? defaultUniverseDomain;
88107
endpoint ??= 'https://iamcredentials.$universeDomain';
89-
return IAMSigner(this, endpoint: endpoint).sign(data);
108+
return (await IAMSigner(this, endpoint: endpoint).sign(data)).signedBlob;
90109
}
91110
}

googleapis_auth/lib/src/iam_signer.dart

Lines changed: 77 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'dart:convert';
6-
import 'dart:io';
7-
6+
import 'package:google_cloud/general.dart';
87
import 'package:http/http.dart' as http;
98

10-
import 'exceptions.dart';
119
import 'utils.dart';
1210

1311
/// Signs data using the IAM Credentials API's signBlob endpoint.
@@ -44,10 +42,8 @@ import 'utils.dart';
4442
/// ```
4543
class IAMSigner {
4644
final http.Client _client;
47-
final String? _serviceAccountEmail;
48-
final String _endpoint;
49-
50-
String? _cachedEmail;
45+
final String? serviceAccountEmail;
46+
final String endpoint;
5147

5248
/// Creates an [IAMSigner] instance.
5349
///
@@ -58,81 +54,105 @@ class IAMSigner {
5854
/// signing. If not provided, it will be fetched from the GCE metadata server.
5955
///
6056
/// [universeDomain] specifies the universe domain for constructing the IAM
61-
/// endpoint. Defaults to [defaultUniverseDomain] (googleapis.com).
57+
/// endpoint. Defaults to [defaultUniverseDomain].
6258
///
6359
/// [endpoint] specifies a custom IAM Credentials API endpoint URL.
6460
/// If provided, takes precedence over [universeDomain].
6561
IAMSigner(
6662
http.Client client, {
67-
String? serviceAccountEmail,
63+
this.serviceAccountEmail,
6864
String? endpoint,
6965
String universeDomain = defaultUniverseDomain,
7066
}) : _client = client,
71-
_serviceAccountEmail = serviceAccountEmail,
72-
_endpoint = endpoint ?? 'https://iamcredentials.$universeDomain';
67+
endpoint = endpoint ?? 'https://iamcredentials.$universeDomain';
7368

7469
/// Returns the service account email.
7570
///
76-
/// If an email was provided in the constructor, returns that email.
71+
/// If an [serviceAccountEmail] was provided in the constructor, returns that
72+
/// email.
7773
/// Otherwise, queries the GCE metadata server to retrieve the default
7874
/// service account email.
79-
Future<String> getServiceAccountEmail() async {
80-
if (_serviceAccountEmail != null) {
81-
return _serviceAccountEmail;
82-
}
83-
84-
if (_cachedEmail != null) {
85-
return _cachedEmail!;
86-
}
87-
88-
final metadataHost =
89-
Platform.environment[gceMetadataHostEnvVar] ?? defaultMetadataHost;
90-
final emailUrl = Uri.parse(
91-
'http://$metadataHost/computeMetadata/v1/instance/service-accounts/default/email',
92-
);
93-
94-
final response = await _client.get(emailUrl, headers: metadataFlavorHeader);
95-
if (response.statusCode != 200) {
96-
throw ServerRequestFailedException(
97-
'Failed to get service account email from metadata server.',
98-
statusCode: response.statusCode,
99-
responseContent: response.body,
75+
///
76+
/// The result is cached for the lifetime of the Dart process.
77+
/// Subsequent calls return the cached value without performing discovery
78+
/// again.
79+
///
80+
/// If [refresh] is `true`, the cache is cleared and the value is re-computed.
81+
///
82+
/// If the metadata server cannot be contacted or returns a non-200 status
83+
/// code, a [MetadataServerException] is thrown.
84+
Future<String> getServiceAccountEmail({bool refresh = false}) async =>
85+
serviceAccountEmail ??
86+
await serviceAccountEmailFromMetadataServer(
87+
client: _client,
88+
refresh: refresh,
10089
);
101-
}
102-
103-
_cachedEmail = response.body.trim();
104-
return _cachedEmail!;
105-
}
10690

10791
/// Signs the given [data] using the IAM Credentials API.
10892
///
109-
/// Returns the signature as a String (base64-encoded).
93+
/// Returns a record containing the signature as a base64-encoded String and
94+
/// the key ID used to sign the blob.
95+
///
96+
/// If [refresh] is `true`, the service account email cache is cleared and
97+
/// re-computed before signing.
98+
///
99+
/// [delegates] specifies the sequence of service accounts in a delegation
100+
/// chain.
101+
///
102+
/// If [serviceAccountEmail] is not set, [getServiceAccountEmail] is called.
110103
///
111-
/// Throws a [ServerRequestFailedException] if the signing operation fails.
112-
Future<String> sign(List<int> data) async {
113-
final email = await getServiceAccountEmail();
104+
/// Each service account must be granted the `roles/iam.serviceAccountTokenCreator`
105+
/// role on its next service account in the chain. The last service account in
106+
/// the chain must be granted the `roles/iam.serviceAccountTokenCreator` role
107+
/// on the service account that is specified by [serviceAccountEmail] (or the
108+
/// discovered service account email).
109+
///
110+
/// Throws [http.ClientException] if the signing operation fails.
111+
Future<({String signedBlob, String keyId})> sign(
112+
List<int> data, {
113+
bool refresh = false,
114+
List<String>? delegates,
115+
}) async {
116+
final email =
117+
serviceAccountEmail ?? await getServiceAccountEmail(refresh: refresh);
114118
final encodedEmail = Uri.encodeComponent(email);
115119

116120
final signBlobUrl = Uri.parse(
117-
'$_endpoint/v1/projects/-/serviceAccounts/$encodedEmail:signBlob',
121+
'$endpoint/v1/projects/-/serviceAccounts/$encodedEmail:signBlob',
118122
);
119123

120-
final requestBody = jsonEncode({'payload': base64Encode(data)});
121-
final request = http.Request('POST', signBlobUrl)
122-
..headers['Content-Type'] = 'application/json'
123-
..body = requestBody;
124+
final requestBody = jsonEncode({
125+
'payload': base64Encode(data),
126+
'delegates': ?delegates,
127+
});
124128

125-
final responseJson = await _client.requestJson(
126-
request,
127-
'Failed to sign blob via IAM.',
129+
final response = await _client.post(
130+
signBlobUrl,
131+
headers: {'Content-Type': 'application/json'},
132+
body: requestBody,
128133
);
129134

130-
return switch (responseJson) {
131-
{'signedBlob': final String signedBlob} => signedBlob,
132-
_ => throw ServerRequestFailedException(
133-
'IAM signBlob response missing signedBlob field.',
134-
responseContent: responseJson,
135-
),
136-
};
135+
if (response.statusCode != 200) {
136+
throw http.ClientException(
137+
'Failed to sign blob via IAM. '
138+
'Status: ${response.statusCode}, Body: ${response.body}',
139+
signBlobUrl,
140+
);
141+
}
142+
143+
final responseJson = jsonDecode(response.body) as Map<String, dynamic>;
144+
145+
if (responseJson case {
146+
'signedBlob': final String signedBlob,
147+
'keyId': final String keyId,
148+
}) {
149+
return (signedBlob: signedBlob, keyId: keyId);
150+
}
151+
152+
throw http.ClientException(
153+
'IAM signBlob response missing signedBlob or keyId field. '
154+
'Body: ${response.body}',
155+
signBlobUrl,
156+
);
137157
}
138158
}

googleapis_auth/lib/src/impersonated_auth_client.dart

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,12 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
160160
'lifetime': '${_lifetime.inSeconds}s',
161161
});
162162

163-
final request = http.Request('POST', tokenUrl)
164-
..headers['Content-Type'] = 'application/json'
165-
..body = requestBody;
166-
167163
final responseJson = await _sourceClient.requestJson(
168-
request,
164+
'POST',
165+
tokenUrl,
169166
'Failed to generate access token for impersonated service account.',
167+
headers: {'Content-Type': 'application/json'},
168+
body: requestBody,
170169
);
171170

172171
final (accessToken, expireTime) = switch (responseJson) {
@@ -192,12 +191,13 @@ class ImpersonatedAuthClient extends AutoRefreshDelegatingClient {
192191
/// This method calls the IAM Credentials API signBlob endpoint to sign data
193192
/// as the impersonated service account.
194193
///
195-
/// Returns the signature as a String
194+
/// Returns a record containing the signature as a base64-encoded String and
195+
/// the key ID used to sign the blob.
196196
///
197197
/// Throws [ServerRequestFailedException] if the signing operation fails.
198198
///
199199
/// See: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob
200-
Future<String> sign(List<int> data) {
200+
Future<({String signedBlob, String keyId})> sign(List<int> data) {
201201
final signer = IAMSigner(
202202
_sourceClient,
203203
serviceAccountEmail: _targetServiceAccount,

0 commit comments

Comments
 (0)