Skip to content

Commit 633cb07

Browse files
authored
[googleapis_auth] feat: Add IAMSigner for signing via IAM Credentials API (#710)
1 parent d5f6d0a commit 633cb07

File tree

7 files changed

+407
-11
lines changed

7 files changed

+407
-11
lines changed

googleapis_auth/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- Add `serviceAccountCredentials` getter to AuthClient
33
- Added parsing for project_id and universe_domain properties for ServiceAccountCredentials
44
- Add `sign()` method to `ServiceAccountCredentials` for RSA-SHA256 signing
5+
- Add `IAMSigner` class for signing via IAM Credentials API
56
- Require `meta: ^1.0.2`
67
- Require `sdk: ^3.9.0`
78
- Drop unneeded `args` dependency.

googleapis_auth/lib/googleapis_auth.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ export 'src/auth_functions.dart';
3131
export 'src/client_id.dart';
3232
export 'src/crypto/rsa.dart' show RSAPrivateKey;
3333
export 'src/exceptions.dart';
34+
export 'src/iam_signer.dart';
3435
export 'src/response_type.dart';
3536
export 'src/service_account_credentials.dart';
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
import 'dart:io';
7+
8+
import 'package:http/http.dart' as http;
9+
10+
import 'exceptions.dart';
11+
import 'utils.dart';
12+
13+
/// Signs data using the IAM Credentials API's signBlob endpoint.
14+
///
15+
/// This is useful when running on Cloud Run or Google Compute Engine with
16+
/// Application Default Credentials, where there's no private key available
17+
/// locally. Instead of signing locally, this class uses the IAM service to
18+
/// perform signing operations.
19+
///
20+
/// Does not close the [http.Client] passed to the constructor.
21+
///
22+
/// See: https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob
23+
///
24+
/// Example usage:
25+
/// ```dart
26+
/// import 'dart:convert';
27+
/// import 'package:googleapis_auth/googleapis_auth.dart';
28+
///
29+
/// // Get an authenticated client (e.g., via metadata server)
30+
/// final authClient = await clientViaMetadataServer();
31+
///
32+
/// // Create an IAMSigner with explicit service account email
33+
/// final signer = IAMSigner(
34+
/// authClient,
35+
/// serviceAccountEmail: 'my-service@project.iam.gserviceaccount.com',
36+
/// );
37+
///
38+
/// // Or let it fetch the email from metadata server
39+
/// final signer = IAMSigner(authClient);
40+
///
41+
/// // Sign some data
42+
/// final data = utf8.encode('data to sign');
43+
/// final signature = await signer.sign(data);
44+
/// ```
45+
class IAMSigner {
46+
final http.Client _client;
47+
final String? _serviceAccountEmail;
48+
final String _endpoint;
49+
50+
String? _cachedEmail;
51+
52+
/// Creates an [IAMSigner] instance.
53+
///
54+
/// [client] is used for making HTTP requests to the metadata server and
55+
/// IAM API.
56+
///
57+
/// [serviceAccountEmail] is the optional service account email to use for
58+
/// signing. If not provided, it will be fetched from the GCE metadata server.
59+
///
60+
/// [endpoint] specifies the IAM Credentials API endpoint.
61+
/// Defaults to `https://iamcredentials.googleapis.com`.
62+
IAMSigner(
63+
http.Client client, {
64+
String? serviceAccountEmail,
65+
String endpoint = 'https://iamcredentials.$defaultUniverseDomain',
66+
}) : _client = client,
67+
_serviceAccountEmail = serviceAccountEmail,
68+
_endpoint = endpoint;
69+
70+
/// Returns the service account email.
71+
///
72+
/// If an email was provided in the constructor, returns that email.
73+
/// Otherwise, queries the GCE metadata server to retrieve the default
74+
/// service account email.
75+
Future<String> getServiceAccountEmail() async {
76+
if (_serviceAccountEmail != null) {
77+
return _serviceAccountEmail;
78+
}
79+
80+
if (_cachedEmail != null) {
81+
return _cachedEmail!;
82+
}
83+
84+
final metadataHost =
85+
Platform.environment[gceMetadataHostEnvVar] ?? defaultMetadataHost;
86+
final emailUrl = Uri.parse(
87+
'http://$metadataHost/computeMetadata/v1/instance/service-accounts/default/email',
88+
);
89+
90+
final response = await _client.get(emailUrl, headers: metadataFlavorHeader);
91+
if (response.statusCode != 200) {
92+
throw ServerRequestFailedException(
93+
'Failed to get service account email from metadata server.',
94+
statusCode: response.statusCode,
95+
responseContent: response.body,
96+
);
97+
}
98+
99+
_cachedEmail = response.body.trim();
100+
return _cachedEmail!;
101+
}
102+
103+
/// Signs the given [data] using the IAM Credentials API.
104+
///
105+
/// Returns the signature as a String (base64-encoded).
106+
///
107+
/// Throws a [ServerRequestFailedException] if the signing operation fails.
108+
Future<String> sign(List<int> data) async {
109+
final email = await getServiceAccountEmail();
110+
final encodedEmail = Uri.encodeComponent(email);
111+
112+
final signBlobUrl = Uri.parse(
113+
'$_endpoint/v1/projects/-/serviceAccounts/$encodedEmail:signBlob',
114+
);
115+
116+
final requestBody = jsonEncode({'payload': base64Encode(data)});
117+
final request = http.Request('POST', signBlobUrl)
118+
..headers['Content-Type'] = 'application/json'
119+
..body = requestBody;
120+
121+
final responseJson = await _client.requestJson(
122+
request,
123+
'Failed to sign blob via IAM.',
124+
);
125+
126+
return switch (responseJson) {
127+
{'signedBlob': final String signedBlob} => signedBlob,
128+
_ => throw ServerRequestFailedException(
129+
'IAM signBlob response missing signedBlob field.',
130+
responseContent: responseJson,
131+
),
132+
};
133+
}
134+
}

googleapis_auth/lib/src/oauth2_flows/metadata_server.dart

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,8 @@ import 'base_flow.dart';
1818
/// metadata server, looking first for one set in the environment under
1919
/// `$GCE_METADATA_HOST`.
2020
class MetadataServerAuthorizationFlow extends BaseFlow {
21-
static const _headers = {'Metadata-Flavor': 'Google'};
2221
static const _serviceAccountUrlInfix =
2322
'computeMetadata/v1/instance/service-accounts';
24-
// https://cloud.google.com/compute/docs/storing-retrieving-metadata#querying
25-
static const _defaultMetadataHost = 'metadata.google.internal';
26-
static const _gceMetadataHostEnvVar = 'GCE_METADATA_HOST';
2723

2824
final String email;
2925
final Uri _scopesUrl;
@@ -37,7 +33,7 @@ class MetadataServerAuthorizationFlow extends BaseFlow {
3733
final encodedEmail = Uri.encodeComponent(email);
3834

3935
final metadataHost =
40-
Platform.environment[_gceMetadataHostEnvVar] ?? _defaultMetadataHost;
36+
Platform.environment[gceMetadataHostEnvVar] ?? defaultMetadataHost;
4137
final serviceAccountPrefix =
4238
'http://$metadataHost/$_serviceAccountUrlInfix';
4339

@@ -62,7 +58,7 @@ class MetadataServerAuthorizationFlow extends BaseFlow {
6258
Future<AccessCredentials> run() async {
6359
final results = await Future.wait([
6460
_client.requestJson(
65-
http.Request('GET', _tokenUrl)..headers.addAll(_headers),
61+
http.Request('GET', _tokenUrl)..headers.addAll(metadataFlavorHeader),
6662
'Failed to obtain access credentials.',
6763
),
6864
_getScopes(),
@@ -80,7 +76,10 @@ class MetadataServerAuthorizationFlow extends BaseFlow {
8076
}
8177

8278
Future<String> _getScopes() async {
83-
final response = await _client.get(_scopesUrl, headers: _headers);
79+
final response = await _client.get(
80+
_scopesUrl,
81+
headers: metadataFlavorHeader,
82+
);
8483
return response.body;
8584
}
8685
}

googleapis_auth/lib/src/service_account_credentials.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'client_id.dart';
99
import 'crypto/pem.dart';
1010
import 'crypto/rsa.dart';
1111
import 'crypto/rsa_sign.dart';
12+
import 'utils.dart';
1213

1314
export 'access_credentials.dart' show AccessCredentials;
1415
export 'access_token.dart' show AccessToken;
@@ -36,7 +37,7 @@ class ServiceAccountCredentials {
3637

3738
/// The universe domain for this service account.
3839
///
39-
/// Defaults to 'googleapis.com' if not specified in the JSON file.
40+
/// Defaults to [defaultUniverseDomain] if not specified in the JSON file.
4041
/// Used to construct correct API endpoints for non-default universe domains
4142
/// (e.g., Government Cloud or other isolated environments).
4243
final String universeDomain;
@@ -66,7 +67,7 @@ class ServiceAccountCredentials {
6667
final type = json['type'];
6768
final projectId = json['project_id'] as String?;
6869
final universeDomain =
69-
json['universe_domain'] as String? ?? 'googleapis.com';
70+
json['universe_domain'] as String? ?? defaultUniverseDomain;
7071

7172
if (type != 'service_account') {
7273
throw ArgumentError(
@@ -110,14 +111,14 @@ class ServiceAccountCredentials {
110111
///
111112
/// The optional named argument [universeDomain] specifies the universe
112113
/// domain.
113-
/// Defaults to 'googleapis.com' if not provided.
114+
/// Defaults to [defaultUniverseDomain] if not provided.
114115
ServiceAccountCredentials(
115116
this.email,
116117
this.clientId,
117118
this.privateKey, {
118119
this.impersonatedUser,
119120
this.projectId,
120-
this.universeDomain = 'googleapis.com',
121+
this.universeDomain = defaultUniverseDomain,
121122
}) : privateRSAKey = keyFromString(privateKey);
122123

123124
/// Signs the given [data] using RSA-SHA256 with this service account's

googleapis_auth/lib/src/utils.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ import 'http_client_base.dart';
1616
/// will shorten expiry dates by 20 seconds.
1717
const maxExpectedTimeDiffInSeconds = 20;
1818

19+
// Metadata server constants
20+
const metadataFlavorHeader = {'Metadata-Flavor': 'Google'};
21+
// - https://cloud.google.com/compute/docs/storing-retrieving-metadata#querying
22+
const defaultMetadataHost = 'metadata.google.internal';
23+
const gceMetadataHostEnvVar = 'GCE_METADATA_HOST';
24+
25+
// Universe domain constants
26+
const defaultUniverseDomain = 'googleapis.com';
27+
1928
AccessToken parseAccessToken(Map<String, dynamic> jsonMap) {
2029
final tokenType = jsonMap['token_type'];
2130
final accessToken = jsonMap['access_token'];

0 commit comments

Comments
 (0)