Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions googleapis_auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from service account credentials JSON.
- `clientViaServiceAccountImpersonation` and `ImpersonatedAuthClient` now accept
an optional `baseClient`.
- Application Default Credentials (ADC) now supports `impersonated_service_account`
source files.
- Application Default Credentials (ADC) now propagate `quota_project_id` for
Service Account credentials.

## 2.1.0

Expand Down
55 changes: 54 additions & 1 deletion googleapis_auth/lib/src/adc_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:http/http.dart';
import 'auth_endpoints.dart';
import 'auth_functions.dart';
import 'auth_http_utils.dart';
import 'impersonated_auth_client.dart';
import 'service_account_client.dart';
import 'service_account_credentials.dart';

Expand All @@ -38,8 +39,20 @@ Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
'Failed to parse JSON from credentials file from $fileSource',
);
}
final quotaProject = credentials['quota_project_id'] as String?;

return _clientViaApplicationCredentials(
credentials as Map<String, dynamic>,
scopes,
baseClient,
);
}

Future<AutoRefreshingAuthClient> _clientViaApplicationCredentials(
Map<String, dynamic> credentials,
List<String> scopes,
Client baseClient,
) async {
final quotaProject = credentials['quota_project_id'] as String?;
if (credentials case {
'type': 'authorized_user',
'client_id': final String clientIdString,
Expand All @@ -64,10 +77,50 @@ Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
quotaProject: quotaProject,
);
}

if (credentials case {
'type': 'impersonated_service_account',
'service_account_impersonation_url': final String url,
'source_credentials': final Map<String, dynamic> source,
}) {
final sourceClient = await _clientViaApplicationCredentials(source, [
'https://www.googleapis.com/auth/iam',
], baseClient);

final match = _impersonationUrlRegExp.firstMatch(url);
if (match == null) {
throw ArgumentError.value(
url,
'service_account_impersonation_url',
'Invalid impersonation URL',
);
}
final targetServiceAccount = match.group(1)!;

return clientViaServiceAccountImpersonation(
sourceClient: sourceClient,
targetServiceAccount: targetServiceAccount,
targetScopes: scopes,
baseClient: baseClient,
);
}

return await clientViaServiceAccount(
ServiceAccountCredentials.fromJson(credentials),
scopes,
baseClient: baseClient,
quotaProject: quotaProject,
);
}

/// Matches the target service account email from a service account
/// impersonation URL.
///
/// Example URL:
/// `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/SA_NAME@PROJECT.iam.gserviceaccount.com:generateAccessToken`
///
/// See:
/// https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
final _impersonationUrlRegExp = RegExp(
r'serviceAccounts/([^:]+):generateAccessToken',
);
116 changes: 116 additions & 0 deletions googleapis_auth/test/adc_impersonation_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2026 Google LLC
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd

@TestOn('vm')
library;

import 'dart:convert';
import 'dart:io';

import 'package:googleapis_auth/src/adc_utils.dart'
show fromApplicationsCredentialsFile;
import 'package:googleapis_auth/src/known_uris.dart';
import 'package:http/http.dart';
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;

import 'test_utils.dart';

void main() {
test('impersonated_service_account credentials', () async {
await d
.file(
'creds.json',
json.encode({
'type': 'impersonated_service_account',
'service_account_impersonation_url':
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@example.com:generateAccessToken',
'source_credentials': {
'client_id': 'id',
'client_secret': 'secret',
'refresh_token': 'refresh',
'type': 'authorized_user',
},
}),
)
.create();

final c = await fromApplicationsCredentialsFile(
File(d.path('creds.json')),
'test-credentials-file',
['target_scope'], // Scopes for the TARGET service account
mockClient(expectClose: false, (Request request) async {
final url = request.url;

// 1. Source Credential Refresh (authorized_user)
if (url == googleOauth2TokenEndpoint) {
expect(request.method, 'POST');
// Expect standard refresh body
expect(request.body, contains('grant_type=refresh_token'));
expect(request.body, contains('refresh_token=refresh'));

return Response(
jsonEncode({
'token_type': 'Bearer',
'access_token': 'source_atoken',
'expires_in': 3600,
}),
200,
headers: jsonContentType,
);
}

// 2. Impersonation Call (generateAccessToken)
if (url.path.endsWith(':generateAccessToken')) {
expect(request.method, 'POST');
// Must use SOURCE token
expect(request.headers['Authorization'], 'Bearer source_atoken');

final body = jsonDecode(request.body) as Map<String, dynamic>;
expect(body['scope'], ['target_scope']);

// Use UTC time for expiry to satisfy AccessToken requirement
return Response(
jsonEncode({
'accessToken': 'target_atoken',
'expireTime': DateTime.now()
.toUtc()
.add(const Duration(hours: 1))
.toIso8601String(),
}),
200,
headers: jsonContentType,
);
}

// 3. Target API Call
if (url.toString() == 'https://storage.googleapis.com/b/bucket/o/obj') {
expect(request.method, 'GET');
// Must use TARGET token
expect(request.headers['Authorization'], 'Bearer target_atoken');
return Response('hello world', 200);
}

return Response('not found: $url', 404);
}),
);

// Initial credentials might be empty or expired until first request,
// BUT clientViaServiceAccountImpersonation fetches initial token
// Yes: "final credentials = await impersonatedClient
// .generateAccessToken();"
// So 'c' should already have valid credentials.
expect(c.credentials.accessToken.data, 'target_atoken');

final r = await c.get(
Uri.https('storage.googleapis.com', '/b/bucket/o/obj'),
);
expect(r.statusCode, 200);
expect(r.body, 'hello world');

c.close();
});
}
Loading