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