From 7ddafa2ab8b8fa2e7999942c62496480d47f443d Mon Sep 17 00:00:00 2001 From: Kevin James Date: Wed, 25 Mar 2026 13:58:18 +0000 Subject: [PATCH 1/3] feat(clients): allow non-dev API endpoint roots (#811) Specifying an API endpoint root to a client used to imply that the endpoint in use was for dev, which would disable TLS and token bearer authorisation. This is not always a valid assumption, for example when manually specifying a locational endpoint for Google PubSub to target a specific region, as such endpoints are for production and should therefore use TLS and authorisation. Fix this by allowing manual configuration of the `api_is_dev` setting when using a non-dev root, whilst maintaining the old behaviour by default for backwards compatibility. Co-Authored-By: Will Miller --- auth/gcloud/aio/auth/token.py | 2 +- bigquery/gcloud/aio/bigquery/bigquery.py | 9 ++++++--- datastore/gcloud/aio/datastore/datastore.py | 15 +++++++++----- kms/gcloud/aio/kms/kms.py | 20 +++++++++++++------ pubsub/gcloud/aio/pubsub/publisher_client.py | 15 +++++++++----- pubsub/gcloud/aio/pubsub/subscriber_client.py | 16 ++++++++++----- storage/gcloud/aio/storage/storage.py | 15 +++++++++----- taskqueue/gcloud/aio/taskqueue/queue.py | 17 +++++++++++----- 8 files changed, 74 insertions(+), 35 deletions(-) diff --git a/auth/gcloud/aio/auth/token.py b/auth/gcloud/aio/auth/token.py index c0cffb5b4..bbbe60b1b 100644 --- a/auth/gcloud/aio/auth/token.py +++ b/auth/gcloud/aio/auth/token.py @@ -94,7 +94,7 @@ def get_service_data( precedence order of various approaches MUST be maintained. It was last updated to match the following commit: - https://github.com/googleapis/google-auth-library-python/blob/6c1297c4d69ba40a8b9392775c17411253fcd73b/google/auth/_default.py#L504 + https://github.com/googleapis/google-auth-library-python/blob/v2.48.0/google/auth/_default.py#L597 """ # pylint: disable=too-complex # _get_explicit_environ_credentials() diff --git a/bigquery/gcloud/aio/bigquery/bigquery.py b/bigquery/gcloud/aio/bigquery/bigquery.py index b2e0fe011..32d5821ee 100644 --- a/bigquery/gcloud/aio/bigquery/bigquery.py +++ b/bigquery/gcloud/aio/bigquery/bigquery.py @@ -25,9 +25,11 @@ log = logging.getLogger(__name__) -def init_api_root(api_root: str | None) -> tuple[bool, str]: +def init_api_root( + api_root: str | None, api_is_dev: bool | None, +) -> tuple[bool, str]: if api_root: - return True, api_root + return api_is_dev is None or api_is_dev, api_root host = os.environ.get('BIGQUERY_EMULATOR_HOST') if host: @@ -66,8 +68,9 @@ def __init__( service_file: str | IO[AnyStr] | None = None, session: Session | None = None, token: Token | None = None, api_root: str | None = None, + api_is_dev: bool | None = None ) -> None: - self._api_is_dev, self._api_root = init_api_root(api_root) + self._api_is_dev, self._api_root = init_api_root(api_root, api_is_dev) self.session = AioSession(session) self.token = token or Token( service_file=service_file, scopes=SCOPES, diff --git a/datastore/gcloud/aio/datastore/datastore.py b/datastore/gcloud/aio/datastore/datastore.py index 69708cc11..a7995a271 100644 --- a/datastore/gcloud/aio/datastore/datastore.py +++ b/datastore/gcloud/aio/datastore/datastore.py @@ -41,9 +41,11 @@ LookUpResult = dict[str, str | list[EntityResult | Key]] -def init_api_root(api_root: str | None) -> tuple[bool, str]: +def init_api_root( + api_root: str | None, api_is_dev: bool | None, +) -> tuple[bool, str]: if api_root: - return True, api_root + return api_is_dev is None or api_is_dev, api_root host = os.environ.get('DATASTORE_EMULATOR_HOST') if host: @@ -68,10 +70,13 @@ class Datastore: def __init__( self, project: str | None = None, service_file: str | IO[AnyStr] | None = None, - namespace: str = '', session: Session | None = None, - token: Token | None = None, api_root: str | None = None, + namespace: str = '', + session: Session | None = None, + token: Token | None = None, + api_root: str | None = None, + api_is_dev: bool | None = None, ) -> None: - self._api_is_dev, self._api_root = init_api_root(api_root) + self._api_is_dev, self._api_root = init_api_root(api_root, api_is_dev) self.namespace = namespace self.session = AioSession(session) self.token = token or Token( diff --git a/kms/gcloud/aio/kms/kms.py b/kms/gcloud/aio/kms/kms.py index 29293369b..ea5117554 100644 --- a/kms/gcloud/aio/kms/kms.py +++ b/kms/gcloud/aio/kms/kms.py @@ -23,9 +23,11 @@ ] -def init_api_root(api_root: str | None) -> tuple[bool, str]: +def init_api_root( + api_root: str | None, api_is_dev: bool | None, +) -> tuple[bool, str]: if api_root: - return True, api_root + return api_is_dev is None or api_is_dev, api_root host = os.environ.get('KMS_EMULATOR_HOST') if host: @@ -39,12 +41,18 @@ class KMS: _api_is_dev: bool def __init__( - self, keyproject: str, keyring: str, keyname: str, + self, + keyproject: str, + keyring: str, + keyname: str, service_file: str | IO[AnyStr] | None = None, - location: str = 'global', session: Session | None = None, - token: Token | None = None, api_root: str | None = None, + location: str = 'global', + session: Session | None = None, + token: Token | None = None, + api_root: str | None = None, + api_is_dev: bool | None = None, ) -> None: - self._api_is_dev, self._api_root = init_api_root(api_root) + self._api_is_dev, self._api_root = init_api_root(api_root, api_is_dev) self._api_root = ( f'{self._api_root}/projects/{keyproject}/locations/{location}/' f'keyRings/{keyring}/cryptoKeys/{keyname}' diff --git a/pubsub/gcloud/aio/pubsub/publisher_client.py b/pubsub/gcloud/aio/pubsub/publisher_client.py index bfd1b1a77..b1a233b46 100644 --- a/pubsub/gcloud/aio/pubsub/publisher_client.py +++ b/pubsub/gcloud/aio/pubsub/publisher_client.py @@ -25,9 +25,11 @@ log = logging.getLogger(__name__) -def init_api_root(api_root: str | None) -> tuple[bool, str]: +def init_api_root( + api_root: str | None, api_is_dev: bool | None, +) -> tuple[bool, str]: if api_root: - return True, api_root + return api_is_dev is None or api_is_dev, api_root host = os.environ.get('PUBSUB_EMULATOR_HOST') if host: @@ -42,11 +44,14 @@ class PublisherClient: # TODO: add project override def __init__( - self, *, service_file: str | IO[AnyStr] | None = None, - session: Session | None = None, token: Token | None = None, + self, *, + service_file: str | IO[AnyStr] | None = None, + session: Session | None = None, + token: Token | None = None, api_root: str | None = None, + api_is_dev: bool | None = None, ) -> None: - self._api_is_dev, self._api_root = init_api_root(api_root) + self._api_is_dev, self._api_root = init_api_root(api_root, api_is_dev) self.session = AioSession(session, verify_ssl=not self._api_is_dev) self.token = token or Token( diff --git a/pubsub/gcloud/aio/pubsub/subscriber_client.py b/pubsub/gcloud/aio/pubsub/subscriber_client.py index 6df39bd5c..78b0da442 100644 --- a/pubsub/gcloud/aio/pubsub/subscriber_client.py +++ b/pubsub/gcloud/aio/pubsub/subscriber_client.py @@ -21,9 +21,11 @@ ] -def init_api_root(api_root: str | None) -> tuple[bool, str]: +def init_api_root( + api_root: str | None, api_is_dev: bool | None, +) -> tuple[bool, str]: if api_root: - return True, api_root + return api_is_dev is None or api_is_dev, api_root host = os.environ.get('PUBSUB_EMULATOR_HOST') if host: @@ -37,11 +39,15 @@ class SubscriberClient: _api_is_dev: bool def __init__( - self, *, service_file: str | IO[AnyStr] | None = None, - token: Token | None = None, session: Session | None = None, + self, + *, + service_file: str | IO[AnyStr] | None = None, + token: Token | None = None, + session: Session | None = None, api_root: str | None = None, + api_is_dev: bool | None = None, ) -> None: - self._api_is_dev, self._api_root = init_api_root(api_root) + self._api_is_dev, self._api_root = init_api_root(api_root, api_is_dev) self.session = AioSession(session, verify_ssl=not self._api_is_dev) self.token = token or Token( diff --git a/storage/gcloud/aio/storage/storage.py b/storage/gcloud/aio/storage/storage.py index a78722000..0264c594a 100644 --- a/storage/gcloud/aio/storage/storage.py +++ b/storage/gcloud/aio/storage/storage.py @@ -42,9 +42,11 @@ log = logging.getLogger(__name__) -def init_api_root(api_root: str | None) -> tuple[bool, str]: +def init_api_root( + api_root: str | None, api_is_dev: bool | None, +) -> tuple[bool, str]: if api_root: - return True, api_root + return api_is_dev is None or api_is_dev, api_root host = os.environ.get('STORAGE_EMULATOR_HOST') if host: @@ -152,11 +154,14 @@ class Storage: _api_root_write: str def __init__( - self, *, service_file: str | IO[AnyStr] | None = None, - token: Token | None = None, session: Session | None = None, + self, *, + service_file: str | IO[AnyStr] | None = None, + token: Token | None = None, + session: Session | None = None, api_root: str | None = None, + api_is_dev: bool | None = None, ) -> None: - self._api_is_dev, self._api_root = init_api_root(api_root) + self._api_is_dev, self._api_root = init_api_root(api_root, api_is_dev) self._api_root_read = f'{self._api_root}/storage/v1/b' self._api_root_write = f'{self._api_root}/upload/storage/v1/b' diff --git a/taskqueue/gcloud/aio/taskqueue/queue.py b/taskqueue/gcloud/aio/taskqueue/queue.py index 9ae0bbf99..39111ef5a 100644 --- a/taskqueue/gcloud/aio/taskqueue/queue.py +++ b/taskqueue/gcloud/aio/taskqueue/queue.py @@ -25,9 +25,11 @@ log = logging.getLogger(__name__) -def init_api_root(api_root: str | None) -> tuple[bool, str]: +def init_api_root( + api_root: str | None, api_is_dev: bool | None, +) -> tuple[bool, str]: if api_root: - return True, api_root + return api_is_dev is None or api_is_dev, api_root host = os.environ.get('CLOUDTASKS_EMULATOR_HOST') if host: @@ -42,12 +44,17 @@ class PushQueue: _queue_path: str def __init__( - self, project: str, taskqueue: str, location: str = 'us-central1', + self, + project: str, + taskqueue: str, + location: str = 'us-central1', service_file: str | IO[AnyStr] | None = None, - session: Session | None = None, token: Token | None = None, + session: Session | None = None, + token: Token | None = None, api_root: str | None = None, + api_is_dev: bool | None = None ) -> None: - self._api_is_dev, self._api_root = init_api_root(api_root) + self._api_is_dev, self._api_root = init_api_root(api_root, api_is_dev) self._queue_path = ( f'projects/{project}/locations/{location}/queues/{taskqueue}' ) From 361cec6186c8b176a20d64c0653769217befb8f4 Mon Sep 17 00:00:00 2001 From: Kevin James Date: Wed, 25 Mar 2026 14:37:19 +0000 Subject: [PATCH 2/3] feat(auth): external credential type in auth (#906) Co-Authored-By: Prahathess Rengasamy --- auth/README.rst | 7 ++ auth/gcloud/aio/auth/__init__.py | 160 +++++++++++++++++++++++++++++++ auth/gcloud/aio/auth/token.py | 118 ++++++++++++++++++++++- auth/tests/unit/token_test.py | 136 ++++++++++++++++++++++++++ 4 files changed, 418 insertions(+), 3 deletions(-) diff --git a/auth/README.rst b/auth/README.rst index 1b3f94e23..46894ad5a 100644 --- a/auth/README.rst +++ b/auth/README.rst @@ -19,6 +19,13 @@ against Google Cloud. The other ``gcloud-aio-*`` package components accept a these components or define one for each. Each component corresponds to a given Google Cloud service and each service requires various "`scopes`_". +The library supports multiple authentication methods: +- Service account credentials +- Authorized user credentials +- GCE metadata credentials +- Impersonated service account credentials +- External account credentials (for workload identity federation) + |pypi| |pythons| Installation diff --git a/auth/gcloud/aio/auth/__init__.py b/auth/gcloud/aio/auth/__init__.py index c035c875b..41bfe42a1 100644 --- a/auth/gcloud/aio/auth/__init__.py +++ b/auth/gcloud/aio/auth/__init__.py @@ -73,6 +73,166 @@ the **roles/iam.serviceAccountTokenCreator** role on the service account that is specified in the ``target_principal``. +Basic Usage +~~~~~~~~~~~ + +.. code-block:: python + + from gcloud.aio.auth import Token + + # Use default credentials (searches for credentials in standard locations) + token = Token() + access_token = await token.get() + + # Use a specific service account file + token = Token(service_file='path/to/service-account.json') + access_token = await token.get() + + # Use a custom session + import aiohttp + async with aiohttp.ClientSession() as session: + token = Token(session=session) + access_token = await token.get() + +Service Account Authentication +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcloud.aio.auth import Token + + # Use service account with specific scopes + token = Token( + service_file='path/to/service-account.json', + scopes=['https://www.googleapis.com/auth/cloud-platform'] + ) + access_token = await token.get() + +Authorized User Authentication +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcloud.aio.auth import Token + + # Use authorized user credentials (e.g., from gcloud auth application-default login) + token = Token(service_file='~/.config/gcloud/application_default_credentials.json') + access_token = await token.get() + +GCE Metadata Authentication +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcloud.aio.auth import Token + + # When running on GCE, the metadata server is used automatically + token = Token() + access_token = await token.get() + +Service Account Impersonation +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcloud.aio.auth import Token + + # Impersonate a service account + token = Token( + service_file='path/to/source-credentials.json', + target_principal='target-service@project.iam.gserviceaccount.com', + scopes=['https://www.googleapis.com/auth/cloud-platform'] + ) + access_token = await token.get() + + # With delegation chain + token = Token( + service_file='path/to/source-credentials.json', + target_principal='target-service@project.iam.gserviceaccount.com', + delegates=['delegate-service@project.iam.gserviceaccount.com'], + scopes=['https://www.googleapis.com/auth/cloud-platform'] + ) + access_token = await token.get() + +External Account Credentials +--------------------------- + +The library supports external account credentials for workload identity +federation. This allows you to use credentials from external identity providers +(like AWS, Azure, or OIDC) to access Google Cloud resources. + +Example configuration file: + +.. code-block:: json + + { + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/pool/subject", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "type": "url", + "url": "http://169.254.169.254/metadata/identity/oauth2/token", + "headers": { + "Metadata": "true" + } + } + } + +Usage: + +.. code-block:: python + + from gcloud.aio.auth import Token + + # Basic usage with external account credentials + token = Token(service_file='path/to/external_account_credentials.json') + access_token = await token.get() + + # With specific scopes + token = Token( + service_file='path/to/external_account_credentials.json', + scopes=['https://www.googleapis.com/auth/cloud-platform'] + ) + access_token = await token.get() + +The library supports multiple credential source types: +- URL: Fetches token from a URL endpoint (supports both plaintext and JSON) +- File: Reads token from a file +- Environment: Gets token from an environment variable + +IAP Token Usage +~~~~~~~~~~~~~ + +.. code-block:: python + + from gcloud.aio.auth import IapToken + + # Basic IAP token usage + iap_token = IapToken('https://your-iap-secured-service.com') + id_token = await iap_token.get() + + # With service account impersonation + iap_token = IapToken( + 'https://your-iap-secured-service.com', + impersonating_service_account='service@project.iam.gserviceaccount.com' + ) + id_token = await iap_token.get() + +IAM Client Usage +~~~~~~~~~~~~~~ + +.. code-block:: python + + from gcloud.aio.auth import IamClient + + # List public keys + client = IamClient() + pubkeys = await client.list_public_keys() + + # Get a specific public key + key = await client.get_public_key('key-id') + CLI --- diff --git a/auth/gcloud/aio/auth/token.py b/auth/gcloud/aio/auth/token.py index bbbe60b1b..057119dfb 100644 --- a/auth/gcloud/aio/auth/token.py +++ b/auth/gcloud/aio/auth/token.py @@ -78,9 +78,10 @@ class Type(enum.Enum): AUTHORIZED_USER = 'authorized_user' + EXTERNAL_ACCOUNT = 'external_account' GCE_METADATA = 'gce_metadata' - SERVICE_ACCOUNT = 'service_account' IMPERSONATED_SERVICE_ACCOUNT = 'impersonated_service_account' + SERVICE_ACCOUNT = 'service_account' def get_service_data( @@ -184,6 +185,18 @@ def __init__( self.service_data = get_service_data(service_file) if self.service_data: self.token_type = Type(self.service_data['type']) + if self.token_type == Type.EXTERNAL_ACCOUNT: + required_fields = { + 'audience', + 'credential_source', + 'subject_token_type', + 'token_url', + } + if required_fields - self.service_data.keys(): + raise ValueError( + 'external_account credentials missing required ' + f"fields: {', '.join(required_fields)}" + ) self.token_uri = self.service_data.get( 'token_uri', 'https://oauth2.googleapis.com/token', ) @@ -366,6 +379,103 @@ async def _refresh_source_authorized_user( return TokenResponse(value=str(content['access_token']), expires_in=int(content['expires_in'])) + async def _refresh_external_account(self, timeout: int) -> TokenResponse: + if not self.service_data: + raise ValueError('external_account auth requires service_data') + + credential_source = self.service_data['credential_source'] + subject_token = await self._get_subject_token( + credential_source, timeout, + ) + + # exchange the subject token for a Google access token + data = { + 'audience': self.service_data['audience'], + 'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange', + 'requested_token_type': ( + 'urn:ietf:params:oauth:token-type:access_token' + ), + 'subject_token': subject_token, + 'subject_token_type': self.service_data['subject_token_type'], + } + # add optional service account impersonation if configured + if self.service_data.get('service_account_impersonation_url'): + data['service_account_impersonation_url'] = self.service_data[ + 'service_account_impersonation_url' + ] + # add optional client ID and secret if configured + if self.service_data.get('client_id'): + data['client_id'] = self.service_data['client_id'] + if self.service_data.get('client_secret'): + data['client_secret'] = self.service_data['client_secret'] + # add scopes if configured + if self.scopes: + data['scope'] = ' '.join(self.scopes) + + resp = await self.session.post( + self.service_data['token_url'], + data=urlencode(data), + headers=REFRESH_HEADERS, + timeout=timeout, + ) + try: + data = await resp.json() + except (AttributeError, TypeError): + data = json.loads(await resp.text()) + + return TokenResponse( + value=data['access_token'], + expires_in=data.get('expires_in', self.default_token_ttl), + ) + + async def _get_subject_token( + self, credential_source: dict[str, Any], timeout: int + ) -> str: + # pylint: disable=too-complex + source_type = credential_source.get('type') + if not source_type: + # TODO: looks like sometimes the type can be found elsewhere or + # needs to be infered. + # https://github.com/talkiq/gcloud-aio/pull/906/changes#r2206959538 + raise ValueError('credential_source is missing type field') + + if source_type == 'url': + url = credential_source['url'] + format_ = credential_source.get('format', {}) + format_type = format_.get('type', 'text') + + resp = await self.session.get( + url, + headers=credential_source.get('headers', {}), + timeout=timeout, + ) + + if format_type == 'json': + try: + data = await resp.json() + except (AttributeError, TypeError): + data = json.loads(await resp.text()) + + token: str = data[format_['subject_token_field_name']] + return token + + try: + return await resp.text() + except (AttributeError, TypeError): + return str(resp.text) + + if source_type == 'file': + try: + with open(credential_source['file'], encoding='utf-8') as f: + return f.read().strip() + except Exception as e: + raise ValueError('failed to read subject token file') from e + + if source_type == 'environment': + return os.environ[credential_source['environment_id']] + + raise ValueError(f'unsupported credential_source type: {source_type}') + async def _refresh_gce_metadata(self, timeout: int) -> TokenResponse: resp = await self.session.get( url=self.token_uri, headers=GCE_METADATA_HEADERS, timeout=timeout, @@ -433,13 +543,15 @@ async def _impersonate(self, token: TokenResponse, async def refresh(self, *, timeout: int) -> TokenResponse: if self.token_type == Type.AUTHORIZED_USER: resp = await self._refresh_authorized_user(timeout=timeout) + elif self.token_type == Type.EXTERNAL_ACCOUNT: + resp = await self._refresh_external_account(timeout=timeout) elif self.token_type == Type.GCE_METADATA: resp = await self._refresh_gce_metadata(timeout=timeout) - elif self.token_type == Type.SERVICE_ACCOUNT: - resp = await self._refresh_service_account(timeout=timeout) elif self.token_type == Type.IMPERSONATED_SERVICE_ACCOUNT: # impersonation requires a source authorized user resp = await self._refresh_source_authorized_user(timeout=timeout) + elif self.token_type == Type.SERVICE_ACCOUNT: + resp = await self._refresh_service_account(timeout=timeout) else: raise Exception(f'unsupported token type {self.token_type}') diff --git a/auth/tests/unit/token_test.py b/auth/tests/unit/token_test.py index ff9f77d48..2db2ff691 100644 --- a/auth/tests/unit/token_test.py +++ b/auth/tests/unit/token_test.py @@ -1,3 +1,4 @@ +# pylint: disable=protected-access import asyncio import io import json @@ -83,3 +84,138 @@ async def test_acquiring_cancellation(): t.acquire_access_token.side_effect = None t.acquire_access_token.return_value = None await t.get() + + +@pytest.mark.asyncio +async def test_external_account_as_io(): + service_data = { + 'type': 'external_account', + 'audience': ( + '//iam.googleapis.com/projects/123456/locations/global' + '/workloadIdentityPools/pool/subject' + ), + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + 'token_url': 'https://sts.googleapis.com/v1/token', + 'credential_source': { + 'type': 'url', + 'url': 'http://169.254.169.254/metadata/identity/oauth2/token', + 'headers': {'Metadata': 'true'}, + }, + } + + service_file = io.StringIO(json.dumps(service_data)) + t = token.BaseToken(service_file=service_file) + + assert t.token_type == token.Type.EXTERNAL_ACCOUNT + assert t.token_uri == 'https://oauth2.googleapis.com/token' + + +@pytest.mark.asyncio +async def test_external_account_missing_required_fields(): + # Missing token_url and credential_source + service_data = { + 'type': 'external_account', + 'audience': ( + '//iam.googleapis.com/projects/123456/locations/global' + '/workloadIdentityPools/pool/subject' + ), + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + } + + service_file = io.StringIO(json.dumps(service_data)) + with mock.patch( + 'gcloud.aio.auth.token.get_service_data', return_value=service_data + ): + with pytest.raises( + ValueError, + match='external_account credentials missing required fields', + ): + await token.Token(service_file=service_file).get() + + +@pytest.mark.asyncio +async def test_external_account_token_refresh(): + service_data = { + 'type': 'external_account', + 'audience': ( + '//iam.googleapis.com/projects/123456/locations/global' + '/workloadIdentityPools/pool/subject' + ), + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + 'token_url': 'https://sts.googleapis.com/v1/token', + 'credential_source': { + 'type': 'url', + 'url': 'http://169.254.169.254/metadata/identity/oauth2/token', + 'headers': {'Metadata': 'true'}, + }, + } + + service_file = io.StringIO(f'{json.dumps(service_data)}') + t = token.Token(service_file=service_file) + + # Mock the session to return a subject token + mock_response = mock.AsyncMock() + mock_response.status = 200 + mock_response.text = 'subject_token_123' + t.session.get = mock.AsyncMock(return_value=mock_response) + + # Mock the token exchange response + mock_token_response = mock.AsyncMock() + mock_token_response.status = 200 + mock_token_response.json = mock.AsyncMock( + return_value={'access_token': 'access_token_123', 'expires_in': 3600} + ) + t.session.post = mock.AsyncMock(return_value=mock_token_response) + + # Test token refresh + token_response = await t._refresh_external_account(timeout=10) + assert token_response.value == 'access_token_123' + assert token_response.expires_in == 3600 + + # Verify the correct requests were made + t.session.get.assert_called_once_with( + 'http://169.254.169.254/metadata/identity/oauth2/token', + headers={'Metadata': 'true'}, + timeout=10, + ) + + t.session.post.assert_called_once() + call_args = t.session.post.call_args + assert call_args[0][0] == 'https://sts.googleapis.com/v1/token' + + +@pytest.mark.asyncio +async def test_external_account_credential_source_types(): + # Test URL credential source + url_source = { + 'type': 'url', + 'url': 'http://example.com/token', + 'headers': {'Authorization': 'Bearer secret'}, + } + t = token.Token() + mock_response = mock.AsyncMock() + mock_response.status = 200 + mock_response.text = 'token_from_url' + t.session.get = mock.AsyncMock(return_value=mock_response) + token_value = await t._get_subject_token(url_source, timeout=10) + assert token_value == 'token_from_url' + + # Test file credential source + file_source = {'type': 'file', 'file': 'test_token.txt'} + with mock.patch( + 'builtins.open', + mock.mock_open(read_data='token_from_file'), + ): + token_value = await t._get_subject_token(file_source, timeout=10) + assert token_value == 'token_from_file' + + # Test environment credential source + env_source = {'type': 'environment', 'environment_id': 'TEST_TOKEN'} + with mock.patch.dict('os.environ', {'TEST_TOKEN': 'token_from_env'}): + token_value = await t._get_subject_token(env_source, timeout=10) + assert token_value == 'token_from_env' + + # Test invalid credential source type + invalid_source = {'type': 'invalid'} + with pytest.raises(ValueError, match='unsupported credential_source type'): + await t._get_subject_token(invalid_source, timeout=10) From 55d1ba5f6da94e34b2743e338ecab65309ca8dfd Mon Sep 17 00:00:00 2001 From: Kevin James Date: Wed, 25 Mar 2026 14:44:48 +0000 Subject: [PATCH 3/3] tests(auth): avoid running aio-only tests in gcloud-rest --- auth/tests/unit/token_test.py | 253 +++++++++++++++++----------------- 1 file changed, 127 insertions(+), 126 deletions(-) diff --git a/auth/tests/unit/token_test.py b/auth/tests/unit/token_test.py index 2db2ff691..72d2cb460 100644 --- a/auth/tests/unit/token_test.py +++ b/auth/tests/unit/token_test.py @@ -85,137 +85,138 @@ async def test_acquiring_cancellation(): t.acquire_access_token.return_value = None await t.get() + @pytest.mark.asyncio + async def test_external_account_as_io(): + service_data = { + 'type': 'external_account', + 'audience': ( + '//iam.googleapis.com/projects/123456/locations/global' + '/workloadIdentityPools/pool/subject' + ), + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + 'token_url': 'https://sts.googleapis.com/v1/token', + 'credential_source': { + 'type': 'url', + 'url': 'http://169.254.169.254/metadata/identity/oauth2/token', + 'headers': {'Metadata': 'true'}, + }, + } + + service_file = io.StringIO(json.dumps(service_data)) + t = token.BaseToken(service_file=service_file) + + assert t.token_type == token.Type.EXTERNAL_ACCOUNT + assert t.token_uri == 'https://oauth2.googleapis.com/token' -@pytest.mark.asyncio -async def test_external_account_as_io(): - service_data = { - 'type': 'external_account', - 'audience': ( - '//iam.googleapis.com/projects/123456/locations/global' - '/workloadIdentityPools/pool/subject' - ), - 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', - 'token_url': 'https://sts.googleapis.com/v1/token', - 'credential_source': { - 'type': 'url', - 'url': 'http://169.254.169.254/metadata/identity/oauth2/token', - 'headers': {'Metadata': 'true'}, - }, - } + @pytest.mark.asyncio + async def test_external_account_missing_required_fields(): + # Missing token_url and credential_source + service_data = { + 'type': 'external_account', + 'audience': ( + '//iam.googleapis.com/projects/123456/locations/global' + '/workloadIdentityPools/pool/subject' + ), + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + } + + service_file = io.StringIO(json.dumps(service_data)) + with mock.patch( + 'gcloud.aio.auth.token.get_service_data', return_value=service_data + ): + with pytest.raises( + ValueError, + match='external_account credentials missing required fields', + ): + await token.Token(service_file=service_file).get() - service_file = io.StringIO(json.dumps(service_data)) - t = token.BaseToken(service_file=service_file) + @pytest.mark.asyncio + async def test_external_account_token_refresh(): + service_data = { + 'type': 'external_account', + 'audience': ( + '//iam.googleapis.com/projects/123456/locations/global' + '/workloadIdentityPools/pool/subject' + ), + 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', + 'token_url': 'https://sts.googleapis.com/v1/token', + 'credential_source': { + 'type': 'url', + 'url': 'http://169.254.169.254/metadata/identity/oauth2/token', + 'headers': {'Metadata': 'true'}, + }, + } + + service_file = io.StringIO(f'{json.dumps(service_data)}') + t = token.Token(service_file=service_file) + + # Mock the session to return a subject token + mock_response = mock.AsyncMock() + mock_response.status = 200 + mock_response.text = 'subject_token_123' + t.session.get = mock.AsyncMock(return_value=mock_response) + + # Mock the token exchange response + mock_token_response = mock.AsyncMock() + mock_token_response.status = 200 + mock_token_response.json = mock.AsyncMock( + return_value={ + 'access_token': 'access_token_123', + 'expires_in': 3600, + }, + ) + t.session.post = mock.AsyncMock(return_value=mock_token_response) + + # Test token refresh + token_response = await t._refresh_external_account(timeout=10) + assert token_response.value == 'access_token_123' + assert token_response.expires_in == 3600 + + # Verify the correct requests were made + t.session.get.assert_called_once_with( + 'http://169.254.169.254/metadata/identity/oauth2/token', + headers={'Metadata': 'true'}, + timeout=10, + ) - assert t.token_type == token.Type.EXTERNAL_ACCOUNT - assert t.token_uri == 'https://oauth2.googleapis.com/token' + t.session.post.assert_called_once() + call_args = t.session.post.call_args + assert call_args[0][0] == 'https://sts.googleapis.com/v1/token' + @pytest.mark.asyncio + async def test_external_account_credential_source_types(): + # Test URL credential source + url_source = { + 'type': 'url', + 'url': 'http://example.com/token', + 'headers': {'Authorization': 'Bearer secret'}, + } + t = token.Token() + mock_response = mock.AsyncMock() + mock_response.status = 200 + mock_response.text = 'token_from_url' + t.session.get = mock.AsyncMock(return_value=mock_response) + token_value = await t._get_subject_token(url_source, timeout=10) + assert token_value == 'token_from_url' + + # Test file credential source + file_source = {'type': 'file', 'file': 'test_token.txt'} + with mock.patch( + 'builtins.open', + mock.mock_open(read_data='token_from_file'), + ): + token_value = await t._get_subject_token(file_source, timeout=10) + assert token_value == 'token_from_file' -@pytest.mark.asyncio -async def test_external_account_missing_required_fields(): - # Missing token_url and credential_source - service_data = { - 'type': 'external_account', - 'audience': ( - '//iam.googleapis.com/projects/123456/locations/global' - '/workloadIdentityPools/pool/subject' - ), - 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', - } + # Test environment credential source + env_source = {'type': 'environment', 'environment_id': 'TEST_TOKEN'} + with mock.patch.dict('os.environ', {'TEST_TOKEN': 'token_from_env'}): + token_value = await t._get_subject_token(env_source, timeout=10) + assert token_value == 'token_from_env' - service_file = io.StringIO(json.dumps(service_data)) - with mock.patch( - 'gcloud.aio.auth.token.get_service_data', return_value=service_data - ): + # Test invalid credential source type + invalid_source = {'type': 'invalid'} with pytest.raises( - ValueError, - match='external_account credentials missing required fields', + ValueError, match='unsupported credential_source type', ): - await token.Token(service_file=service_file).get() - - -@pytest.mark.asyncio -async def test_external_account_token_refresh(): - service_data = { - 'type': 'external_account', - 'audience': ( - '//iam.googleapis.com/projects/123456/locations/global' - '/workloadIdentityPools/pool/subject' - ), - 'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt', - 'token_url': 'https://sts.googleapis.com/v1/token', - 'credential_source': { - 'type': 'url', - 'url': 'http://169.254.169.254/metadata/identity/oauth2/token', - 'headers': {'Metadata': 'true'}, - }, - } - - service_file = io.StringIO(f'{json.dumps(service_data)}') - t = token.Token(service_file=service_file) - - # Mock the session to return a subject token - mock_response = mock.AsyncMock() - mock_response.status = 200 - mock_response.text = 'subject_token_123' - t.session.get = mock.AsyncMock(return_value=mock_response) - - # Mock the token exchange response - mock_token_response = mock.AsyncMock() - mock_token_response.status = 200 - mock_token_response.json = mock.AsyncMock( - return_value={'access_token': 'access_token_123', 'expires_in': 3600} - ) - t.session.post = mock.AsyncMock(return_value=mock_token_response) - - # Test token refresh - token_response = await t._refresh_external_account(timeout=10) - assert token_response.value == 'access_token_123' - assert token_response.expires_in == 3600 - - # Verify the correct requests were made - t.session.get.assert_called_once_with( - 'http://169.254.169.254/metadata/identity/oauth2/token', - headers={'Metadata': 'true'}, - timeout=10, - ) - - t.session.post.assert_called_once() - call_args = t.session.post.call_args - assert call_args[0][0] == 'https://sts.googleapis.com/v1/token' - - -@pytest.mark.asyncio -async def test_external_account_credential_source_types(): - # Test URL credential source - url_source = { - 'type': 'url', - 'url': 'http://example.com/token', - 'headers': {'Authorization': 'Bearer secret'}, - } - t = token.Token() - mock_response = mock.AsyncMock() - mock_response.status = 200 - mock_response.text = 'token_from_url' - t.session.get = mock.AsyncMock(return_value=mock_response) - token_value = await t._get_subject_token(url_source, timeout=10) - assert token_value == 'token_from_url' - - # Test file credential source - file_source = {'type': 'file', 'file': 'test_token.txt'} - with mock.patch( - 'builtins.open', - mock.mock_open(read_data='token_from_file'), - ): - token_value = await t._get_subject_token(file_source, timeout=10) - assert token_value == 'token_from_file' - - # Test environment credential source - env_source = {'type': 'environment', 'environment_id': 'TEST_TOKEN'} - with mock.patch.dict('os.environ', {'TEST_TOKEN': 'token_from_env'}): - token_value = await t._get_subject_token(env_source, timeout=10) - assert token_value == 'token_from_env' - - # Test invalid credential source type - invalid_source = {'type': 'invalid'} - with pytest.raises(ValueError, match='unsupported credential_source type'): - await t._get_subject_token(invalid_source, timeout=10) + await t._get_subject_token(invalid_source, timeout=10)