diff --git a/googleapis_auth/CHANGELOG.md b/googleapis_auth/CHANGELOG.md index ca11be2f1..8ac014dcc 100644 --- a/googleapis_auth/CHANGELOG.md +++ b/googleapis_auth/CHANGELOG.md @@ -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. diff --git a/googleapis_auth/lib/src/adc_utils.dart b/googleapis_auth/lib/src/adc_utils.dart index 93f6f504a..73c38b40c 100644 --- a/googleapis_auth/lib/src/adc_utils.dart +++ b/googleapis_auth/lib/src/adc_utils.dart @@ -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 fromApplicationsCredentialsFile( File file, @@ -105,6 +106,58 @@ Future _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 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, diff --git a/googleapis_auth/lib/src/sts_auth_client.dart b/googleapis_auth/lib/src/sts_auth_client.dart new file mode 100644 index 000000000..9d516fd6a --- /dev/null +++ b/googleapis_auth/lib/src/sts_auth_client.dart @@ -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 _credentialSource; + final String _audience; + final String _subjectTokenType; + final String _tokenUrl; + final List _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 credentialSource, + required String audience, + required String subjectTokenType, + required String tokenUrl, + required List 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 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 _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?; + final format = _credentialSource['format'] as Map?; + + 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; + 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.', + ); + } + + @override + Future 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 clientViaStsTokenExchange({ + required Map credentialSource, + required String audience, + required String subjectTokenType, + required String tokenUrl, + required List 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; + } +} diff --git a/googleapis_auth/test/adc_test.dart b/googleapis_auth/test/adc_test.dart index 617d3fece..fbaf63535 100644 --- a/googleapis_auth/test/adc_test.dart +++ b/googleapis_auth/test/adc_test.dart @@ -198,4 +198,141 @@ void main() { c.close(); }); + + test('external_account credentials (WIF)', () async { + await d.file('subject_token.txt', 'my-subject-token').create(); + + await d + .file( + 'creds.json', + json.encode({ + 'type': 'external_account', + 'audience': 'my-audience', + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + 'token_url': 'https://sts.googleapis.com/v1/token', + 'credential_source': {'file': d.path('subject_token.txt')}, + 'quota_project_id': 'project', + }), + ) + .create(); + + final c = await fromApplicationsCredentialsFile( + File(d.path('creds.json')), + 'test-credentials-file', + ['s1'], + mockClient(expectClose: false, (Request request) async { + final url = request.url; + if (url.toString() == 'https://sts.googleapis.com/v1/token') { + expect(request.method, 'POST'); + final body = jsonDecode(request.body) as Map; + expect(body['audience'], 'my-audience'); + expect(body['subjectToken'], 'my-subject-token'); + return Response( + jsonEncode({ + 'token_type': 'Bearer', + 'access_token': 'atoken', + 'expires_in': 3600, + }), + 200, + headers: jsonContentType, + ); + } + if (url.toString() == 'https://storage.googleapis.com/b/bucket/o/obj') { + expect(request.method, 'GET'); + expect( + request.headers, + containsPair('Authorization', 'Bearer atoken'), + ); + expect( + request.headers, + containsPair('X-Goog-User-Project', 'project'), + ); + return Response('hello world', 200); + } + return Response('bad', 404); + }), + ); + expect(c.credentials.accessToken.data, '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(); + }); + + test('external_account credentials (WIF) with impersonation', () async { + await d.file('subject_token2.txt', 'my-subject-token2').create(); + + await d + .file( + 'creds2.json', + json.encode({ + 'type': 'external_account', + 'audience': 'my-audience', + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + 'service_account_impersonation_url': + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/foo@bar.iam.gserviceaccount.com:generateAccessToken', + 'token_url': 'https://sts.googleapis.com/v1/token', + 'credential_source': {'file': d.path('subject_token2.txt')}, + }), + ) + .create(); + + final c = await fromApplicationsCredentialsFile( + File(d.path('creds2.json')), + 'test-credentials-file', + ['s1'], + mockClient(expectClose: false, (Request request) async { + final url = request.url; + if (url.toString() == 'https://sts.googleapis.com/v1/token') { + return Response( + jsonEncode({ + 'token_type': 'Bearer', + 'access_token': 'atoken', + 'expires_in': 3600, + }), + 200, + headers: jsonContentType, + ); + } + if (url.toString() == + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/foo%40bar.iam.gserviceaccount.com:generateAccessToken') { + expect(request.method, 'POST'); + expect( + request.headers, + containsPair('Authorization', 'Bearer atoken'), + ); + return Response( + jsonEncode({ + 'accessToken': 'impersonated-token', + 'expireTime': '2014-10-02T15:01:23.045123456Z', + }), + 200, + headers: jsonContentType, + ); + } + if (url.toString() == 'https://storage.googleapis.com/b/bucket/o/obj') { + expect(request.method, 'GET'); + expect( + request.headers, + containsPair('Authorization', 'Bearer impersonated-token'), + ); + return Response('hello world', 200); + } + return Response('bad url: $url', 404); + }), + ); + expect(c.credentials.accessToken.data, 'impersonated-token'); + + 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(); + }); } diff --git a/googleapis_auth/test/sts_auth_client_test.dart b/googleapis_auth/test/sts_auth_client_test.dart new file mode 100644 index 000000000..156245573 --- /dev/null +++ b/googleapis_auth/test/sts_auth_client_test.dart @@ -0,0 +1,83 @@ +// 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 'package:googleapis_auth/src/sts_auth_client.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('clientViaStsTokenExchange file credentials', () async { + await d.file('token.txt', 'my-token').create(); + + final c = await clientViaStsTokenExchange( + credentialSource: {'file': d.path('token.txt')}, + audience: 'my-audience', + subjectTokenType: 'my-token-type', + tokenUrl: 'https://sts.googleapis.com/v1/token', + scopes: ['s1'], + baseClient: mockClient(expectClose: false, (Request request) async { + if (request.url.toString() == 'https://sts.googleapis.com/v1/token') { + final body = jsonDecode(request.body) as Map; + expect(body['subjectToken'], 'my-token'); + expect(body['audience'], 'my-audience'); + return Response( + jsonEncode({ + 'token_type': 'Bearer', + 'access_token': 'atoken', + 'expires_in': 3600, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return Response('not found', 404); + }), + ); + + expect(c.credentials.accessToken.data, 'atoken'); + }); + + test('clientViaStsTokenExchange url credentials json format', () async { + final c = await clientViaStsTokenExchange( + credentialSource: { + 'url': 'http://localhost/token', + 'headers': {'x-header': 'value'}, + 'format': {'type': 'json', 'subject_token_field_name': 'special_token'}, + }, + audience: 'my-audience', + subjectTokenType: 'my-token-type', + tokenUrl: 'https://sts.googleapis.com/v1/token', + scopes: ['s1'], + baseClient: mockClient(expectClose: false, (Request request) async { + if (request.url.toString() == 'http://localhost/token') { + expect(request.headers['x-header'], 'value'); + return Response(jsonEncode({'special_token': 'my-url-token'}), 200); + } + if (request.url.toString() == 'https://sts.googleapis.com/v1/token') { + final body = jsonDecode(request.body) as Map; + expect(body['subjectToken'], 'my-url-token'); + return Response( + jsonEncode({ + 'token_type': 'Bearer', + 'access_token': 'atoken', + 'expires_in': 3600, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return Response('not found', 404); + }), + ); + + expect(c.credentials.accessToken.data, 'atoken'); + }); +}