Skip to content
Merged
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
2 changes: 2 additions & 0 deletions googleapis_auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
an optional `baseClient`.
- Application Default Credentials (ADC) now supports `impersonated_service_account`
source files.
- Application Default Credentials (ADC) now supports `external_account` source files
for Workload Identity Federation using `Google Security Token Service`.
- Application Default Credentials (ADC) now propagate `quota_project_id` for
Service Account credentials.

Expand Down
53 changes: 53 additions & 0 deletions googleapis_auth/lib/src/adc_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'auth_http_utils.dart';
import 'impersonated_auth_client.dart';
import 'service_account_client.dart';
import 'service_account_credentials.dart';
import 'sts_auth_client.dart';

Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
File file,
Expand Down Expand Up @@ -105,6 +106,58 @@ Future<AutoRefreshingAuthClient> _clientViaApplicationCredentials(
);
}

if (credentials case {
'type': 'external_account',
'audience': final String audience,
'subject_token_type': final String subjectTokenType,
'token_url': final String tokenUrl,
'credential_source': final Map<String, dynamic> credentialSource,
}) {
final serviceAccountImpersonationUrl =
credentials['service_account_impersonation_url'] as String?;

final stsClient = await clientViaStsTokenExchange(
credentialSource: credentialSource,
audience: audience,
subjectTokenType: subjectTokenType,
tokenUrl: tokenUrl,
scopes: scopes,
quotaProject: credentials['quota_project_id'] as String?,
baseClient: baseClient,
);

if (serviceAccountImpersonationUrl != null) {
// It's possible for external credentials to specify a service account
// to impersonate. This is common in Workload Identity Federation where
// the external identity (e.g. AWS, Azure, OIDC) is first exchanged for
// an STS token, which is then used to impersonate a specific Google Cloud
// service account.
//
// See: https://cloud.google.com/iam/docs/workload-identity-federation
// See also the "service_account_impersonation_url" definition at:
// https://google.aip.dev/auth/4117
final match = _impersonationUrlRegExp.firstMatch(
serviceAccountImpersonationUrl,
);
if (match == null) {
throw ArgumentError.value(
serviceAccountImpersonationUrl,
'service_account_impersonation_url',
'Invalid impersonation URL',
);
}
final targetServiceAccount = match.group(1)!;

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

return await clientViaServiceAccount(
ServiceAccountCredentials.fromJson(credentials),
scopes,
Expand Down
209 changes: 209 additions & 0 deletions googleapis_auth/lib/src/sts_auth_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// 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

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

import 'package:http/http.dart' as http;

import 'auth_http_utils.dart';
import 'service_account_credentials.dart';
import 'utils.dart';

/// An authenticated HTTP client that exchanges an external credential for a
/// Google access token using the Google Security Token Service (STS) API.
///
/// This client allows external workloads (like AWS, Azure, OIDC) to access
/// Google Cloud resources using Workload Identity Federation.
class StsAuthClient extends AutoRefreshDelegatingClient {
final Map<String, dynamic> _credentialSource;
final String _audience;
final String _subjectTokenType;
final String _tokenUrl;
final List<String> _scopes;
final String? _quotaProject;

AccessCredentials _credentials;
http.Client? _authClient;

/// Creates an [StsAuthClient] instance.
///
/// [credentialSource] is a map describing how to retrieve the external token.
/// It typically contains a 'file' or 'url' key.
///
/// [audience] is the audience for the token exchange.
///
/// [subjectTokenType] specifies the type of the external token (e.g.,
/// `urn:ietf:params:oauth:token-type:jwt`).
///
/// [tokenUrl] is the endpoint for the token exchange, usually
/// `https://sts.googleapis.com/v1/token`.
///
/// [scopes] are the OAuth2 scopes to request.
///
/// [baseClient] is an optional [http.Client] that will be used for
/// the returned client's authenticated requests and for retrieving external
/// tokens.
StsAuthClient({
required Map<String, dynamic> credentialSource,
required String audience,
required String subjectTokenType,
required String tokenUrl,
required List<String> scopes,
String? quotaProject,
http.Client? baseClient,
}) : _credentialSource = credentialSource,
_audience = audience,
_subjectTokenType = subjectTokenType,
_tokenUrl = tokenUrl,
_scopes = List.unmodifiable(scopes),
_quotaProject = quotaProject,
_credentials = AccessCredentials(
AccessToken('Bearer', '', DateTime.now().toUtc()),
null,
scopes,
),
super(
baseClient ?? http.Client(),
closeUnderlyingClient: baseClient == null,
);

@override
AccessCredentials get credentials => _credentials;

/// Injects the generated credentials. Set internally during initialization.
set initialCredentials(AccessCredentials credentials) {
_credentials = credentials;
}

/// Generates a new access token via STS token exchange.
///
/// This retrieves the subject token and exchanges it for a federated access
/// token via the STS API.
Future<AccessCredentials> generateAccessToken() async {
final subjectToken = await _getSubjectToken();

final responseJson = await baseClient.requestJson(
'POST',
Uri.parse(_tokenUrl),
'Failed to exchange external account credential for access token.',
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'audience': _audience,
'grantType': 'urn:ietf:params:oauth:grant-type:token-exchange',
'requestedTokenType': 'urn:ietf:params:oauth:token-type:access_token',
'subjectTokenType': _subjectTokenType,
'subjectToken': subjectToken,
'scope': _scopes.join(' '),
}),
);

final (accessToken, expiresIn) = switch (responseJson) {
{'access_token': final String t, 'expires_in': final int e} => (t, e),
_ => throw ServerRequestFailedException(
'STS generateAccessToken response missing required fields.',
responseContent: responseJson,
),
};

return AccessCredentials(
AccessToken('Bearer', accessToken, expiryDate(expiresIn)),
null,
_scopes,
);
}

Future<String> _getSubjectToken() async {
if (_credentialSource.containsKey('file')) {
final fileField = _credentialSource['file'] as String;
return await File(fileField).readAsString();
} else if (_credentialSource.containsKey('url')) {
final url = _credentialSource['url'] as String;
final headers = _credentialSource['headers'] as Map<String, dynamic>?;
final format = _credentialSource['format'] as Map<String, dynamic>?;

final parsedHeaders = headers?.map(
(key, value) => MapEntry(key, value.toString()),
);

final response = await baseClient.get(
Uri.parse(url),
headers: parsedHeaders,
);

if (response.statusCode != 200) {
throw Exception(
'Failed to retrieve subject token from URL: $url. '
'Status code: ${response.statusCode}, Body: ${response.body}',
);
}

var token = response.body;

if (format != null && format['type'] == 'json') {
final json = jsonDecode(token) as Map<String, dynamic>;
final subjectTokenFieldName =
format['subject_token_field_name'] as String;
token = json[subjectTokenFieldName] as String;
}
return token;
}
throw UnsupportedError(
'Unsupported credential source type. Must provide file or url.',
);
}
Comment on lines +119 to +157

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The _getSubjectToken method uses unsafe casts (as) which can lead to runtime errors if the credential_source map has an unexpected structure. It would be more robust to use pattern matching to safely access and cast values from the map. Additionally, throwing a generic Exception for HTTP failures is not ideal; a more specific exception like ServerRequestFailedException would be more consistent with the rest of the codebase and provide better error handling for consumers.

  Future<String> _getSubjectToken() async {
    final source = _credentialSource;
    if (source case {'file': final String file}) {
      return await File(file).readAsString();
    } else if (source case {'url': final String url}) {
      final headers = switch (source['headers']) {
        final Map<String, dynamic> h =>
          h.map((key, value) => MapEntry(key, value.toString())),
        _ => null,
      };

      final response = await baseClient.get(
        Uri.parse(url),
        headers: headers,
      );

      if (response.statusCode != 200) {
        throw ServerRequestFailedException(
          'Failed to retrieve subject token from URL: $url. '
          'Status code: ${response.statusCode}',
          responseContent: response.body,
          statusCode: response.statusCode,
        );
      }

      var token = response.body;

      if (source['format']
          case {
            'type': 'json',
            'subject_token_field_name': final String fieldName
          }) {
        final json = jsonDecode(token) as Map<String, dynamic>;
        if (json[fieldName] case final String subjectToken) {
          token = subjectToken;
        } else {
          throw ArgumentError(
            'Subject token field "$fieldName" not found in JSON response.',
          );
        }
      }
      return token;
    }
    throw UnsupportedError(
      'Unsupported credential source type. Must provide file or url.',
    );
  }


@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (_credentials.accessToken.hasExpired) {
final newCredentials = await generateAccessToken();
notifyAboutNewCredentials(newCredentials);
_credentials = newCredentials;
_authClient = AuthenticatedClient(
baseClient,
_credentials,
quotaProject: _quotaProject,
);
}

_authClient ??= AuthenticatedClient(
baseClient,
_credentials,
quotaProject: _quotaProject,
);
return _authClient!.send(request);
}
}

/// Obtains oauth2 credentials by exchanging an external credential for a
/// Google access token.
Future<StsAuthClient> clientViaStsTokenExchange({
required Map<String, dynamic> credentialSource,
required String audience,
required String subjectTokenType,
required String tokenUrl,
required List<String> scopes,
String? quotaProject,
http.Client? baseClient,
}) async {
final stsClient = StsAuthClient(
credentialSource: credentialSource,
audience: audience,
subjectTokenType: subjectTokenType,
tokenUrl: tokenUrl,
scopes: scopes,
quotaProject: quotaProject,
baseClient: baseClient,
);

try {
stsClient.initialCredentials = await stsClient.generateAccessToken();
return stsClient;
} catch (e) {
stsClient.close();
rethrow;
}
}
Loading
Loading