diff --git a/CHANGELOG.md b/CHANGELOG.md index f65921b5d..5a3cc94cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ [1]: https://pypi.org/project/google-auth/#history +## [2.15.0](https://github.com/googleapis/google-auth-library-python/compare/v2.14.1...v2.15.0) (2022-12-01) + + +### Features + +* Add api_key credentials ([#1184](https://github.com/googleapis/google-auth-library-python/issues/1184)) ([370293e](https://github.com/googleapis/google-auth-library-python/commit/370293e84a14af0d6c6b34287bdcad020e0580e4)) +* Introduce a way to provide scopes granted by user ([#1189](https://github.com/googleapis/google-auth-library-python/issues/1189)) ([189f504](https://github.com/googleapis/google-auth-library-python/commit/189f504cbdfe043949688dfe55f3f449befad991)) + + +### Bug Fixes + +* Allow mtls sts endpoint for external account token urls. ([#1185](https://github.com/googleapis/google-auth-library-python/issues/1185)) ([c86dd69](https://github.com/googleapis/google-auth-library-python/commit/c86dd69cf79809e2d532a745a236db840fd8bc5d)) +* CI broken by removal of py.path ([#1194](https://github.com/googleapis/google-auth-library-python/issues/1194)) ([f719415](https://github.com/googleapis/google-auth-library-python/commit/f719415475e10e5af9ec75b3b13c57c25682bea0)) +* Ensure JWT segments have the right types ([#1162](https://github.com/googleapis/google-auth-library-python/issues/1162)) ([fc843cd](https://github.com/googleapis/google-auth-library-python/commit/fc843cd318e4ac4f40cf83bbcd7c6eae2b597ff8)) +* Updated the lower bound of interactive timeout and fix the kwarg… ([#1182](https://github.com/googleapis/google-auth-library-python/issues/1182)) ([50c0fd2](https://github.com/googleapis/google-auth-library-python/commit/50c0fd29a3b6a4fd6dc4b801d883f5d2b6de88c6)) + ## [2.14.1](https://github.com/googleapis/google-auth-library-python/compare/v2.14.0...v2.14.1) (2022-11-07) diff --git a/google/auth/_default.py b/google/auth/_default.py index 67a1c369d..0860c67fe 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -479,6 +479,13 @@ def _get_gdch_service_account_credentials(filename, info): return credentials, info.get("project") +def get_api_key_credentials(key): + """Return credentials with the given API key.""" + from google.auth import api_key + + return api_key.Credentials(key) + + def _apply_quota_project_id(credentials, quota_project_id): if quota_project_id: credentials = credentials.with_quota_project(quota_project_id) diff --git a/google/auth/api_key.py b/google/auth/api_key.py new file mode 100644 index 000000000..49c6ffd2d --- /dev/null +++ b/google/auth/api_key.py @@ -0,0 +1,75 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Google API key support. +This module provides authentication using the `API key`_. +.. _API key: + https://cloud.google.com/docs/authentication/api-keys/ +""" + +from google.auth import _helpers +from google.auth import credentials + + +class Credentials(credentials.Credentials): + """API key credentials. + These credentials use API key to provide authorization to applications. + """ + + def __init__(self, token): + """ + Args: + token (str): API key string + Raises: + ValueError: If the provided API key is not a non-empty string. + """ + super(Credentials, self).__init__() + if not token: + raise ValueError("Token must be a non-empty API key string") + self.token = token + + @property + def expired(self): + return False + + @property + def valid(self): + return True + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + return + + def apply(self, headers, token=None): + """Apply the API key token to the x-goog-api-key header. + Args: + headers (Mapping): The HTTP request headers. + token (Optional[str]): If specified, overrides the current access + token. + """ + headers["x-goog-api-key"] = token or self.token + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the x-goog-api-key header. + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + method (str): The request's HTTP method or the RPC method being + invoked. + url (str): The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + self.apply(headers) diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 7edb55f63..4249529e8 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -437,11 +437,11 @@ def _initialize_impersonated_credentials(self): @staticmethod def validate_token_url(token_url, url_type="token"): _TOKEN_URL_PATTERNS = [ - "^[^\\.\\s\\/\\\\]+\\.sts\\.googleapis\\.com$", - "^sts\\.googleapis\\.com$", - "^sts\\.[^\\.\\s\\/\\\\]+\\.googleapis\\.com$", - "^[^\\.\\s\\/\\\\]+\\-sts\\.googleapis\\.com$", - "^sts\\-[^\\.\\s\\/\\\\]+\\.p\\.googleapis\\.com$", + "^[^\\.\\s\\/\\\\]+\\.sts(?:\\.mtls)?\\.googleapis\\.com$", + "^sts(?:\\.mtls)?\\.googleapis\\.com$", + "^sts\\.[^\\.\\s\\/\\\\]+(?:\\.mtls)?\\.googleapis\\.com$", + "^[^\\.\\s\\/\\\\]+\\-sts(?:\\.mtls)?\\.googleapis\\.com$", + "^sts\\-[^\\.\\s\\/\\\\]+\\.p(?:\\.mtls)?\\.googleapis\\.com$", ] if not Credentials.is_valid_url(_TOKEN_URL_PATTERNS, token_url): diff --git a/google/auth/jwt.py b/google/auth/jwt.py index 9d7b76e73..21de8fe95 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -133,11 +133,12 @@ def _unverified_decode(token): token (Union[str, bytes]): The encoded JWT. Returns: - Tuple[str, str, str, str]: header, payload, signed_section, and + Tuple[Mapping, Mapping, str, str]: header, payload, signed_section, and signature. Raises: - ValueError: if there are an incorrect amount of segments in the token. + ValueError: if there are an incorrect amount of segments in the token or + segments of the wrong type. """ token = _helpers.to_bytes(token) @@ -152,6 +153,16 @@ def _unverified_decode(token): header = _decode_jwt_segment(encoded_header) payload = _decode_jwt_segment(encoded_payload) + if not isinstance(header, Mapping): + raise ValueError( + "Header segment should be a JSON object: {0}".format(encoded_header) + ) + + if not isinstance(payload, Mapping): + raise ValueError( + "Payload segment should be a JSON object: {0}".format(encoded_payload) + ) + return header, payload, signed_section, signature diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 6be8222c1..b4fa448b8 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -52,8 +52,7 @@ EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 1000 # 5 seconds EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND = 120 * 1000 # 2 minutes -EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT = 5 * 60 * 1000 # 5 minutes -EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 60 * 1000 # 5 minutes +EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND = 30 * 1000 # 30 seconds EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND = 30 * 60 * 1000 # 30 minutes @@ -132,7 +131,9 @@ def __init__( self._credential_source_executable_output_file = self._credential_source_executable.get( "output_file" ) - self._tokeninfo_username = kwargs.get("tokeninfo_username", "") # dummy value + + # Dummy value. This variable is only used via injection, not exposed to ctor + self._tokeninfo_username = "" if not self._credential_source_executable_command: raise ValueError( @@ -150,17 +151,16 @@ def __init__( ): raise ValueError("Timeout must be between 5 and 120 seconds.") - if not self._credential_source_executable_interactive_timeout_millis: - self._credential_source_executable_interactive_timeout_millis = ( - EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT - ) - elif ( - self._credential_source_executable_interactive_timeout_millis - < EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND - or self._credential_source_executable_interactive_timeout_millis - > EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND - ): - raise ValueError("Interactive timeout must be between 5 and 30 minutes.") + if self._credential_source_executable_interactive_timeout_millis: + if ( + self._credential_source_executable_interactive_timeout_millis + < EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND + or self._credential_source_executable_interactive_timeout_millis + > EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND + ): + raise ValueError( + "Interactive timeout must be between 30 seconds and 30 minutes." + ) @_helpers.copy_docstring(external_account.Credentials) def retrieve_subject_token(self, request): @@ -400,5 +400,13 @@ def _validate_running_mode(self): "An output_file must be specified in the credential configuration for interactive mode." ) + if ( + self.interactive + and not self._credential_source_executable_interactive_timeout_millis + ): + raise ValueError( + "Interactive mode cannot run without an interactive timeout." + ) + if self.interactive and not self.is_workforce_pool: raise ValueError("Interactive mode is only enabled for workforce pool.") diff --git a/google/auth/version.py b/google/auth/version.py index 5a2a1ee06..f0ecd5d63 100644 --- a/google/auth/version.py +++ b/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.14.1" +__version__ = "2.15.0" diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 8f1c3dda4..457db76ae 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -34,6 +34,7 @@ from datetime import datetime import io import json +import logging import six @@ -43,6 +44,8 @@ from google.auth import exceptions from google.oauth2 import reauth +_LOGGER = logging.getLogger(__name__) + # The Google OAuth 2.0 token endpoint. Used for authorized user credentials. _GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" @@ -79,6 +82,7 @@ def __init__( rapt_token=None, refresh_handler=None, enable_reauth_refresh=False, + granted_scopes=None, ): """ Args: @@ -117,6 +121,9 @@ def __init__( retrieving downscoped tokens from a token broker. enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow should be used. This flag is for gcloud to use only. + granted_scopes (Optional[Sequence[str]]): The scopes that were consented/granted by the user. + This could be different from the requested scopes and it could be empty if granted + and requested scopes were same. """ super(Credentials, self).__init__() self.token = token @@ -125,6 +132,7 @@ def __init__( self._id_token = id_token self._scopes = scopes self._default_scopes = default_scopes + self._granted_scopes = granted_scopes self._token_uri = token_uri self._client_id = client_id self._client_secret = client_secret @@ -155,6 +163,7 @@ def __setstate__(self, d): self._id_token = d.get("_id_token") self._scopes = d.get("_scopes") self._default_scopes = d.get("_default_scopes") + self._granted_scopes = d.get("_granted_scopes") self._token_uri = d.get("_token_uri") self._client_id = d.get("_client_id") self._client_secret = d.get("_client_secret") @@ -174,6 +183,11 @@ def scopes(self): """Optional[str]: The OAuth 2.0 permission scopes.""" return self._scopes + @property + def granted_scopes(self): + """Optional[Sequence[str]]: The OAuth 2.0 permission scopes that were granted by the user.""" + return self._granted_scopes + @property def token_uri(self): """Optional[str]: The OAuth 2.0 authorization server's token endpoint @@ -249,6 +263,7 @@ def with_quota_project(self, quota_project_id): client_secret=self.client_secret, scopes=self.scopes, default_scopes=self.default_scopes, + granted_scopes=self.granted_scopes, quota_project_id=quota_project_id, rapt_token=self.rapt_token, enable_reauth_refresh=self._enable_reauth_refresh, @@ -266,6 +281,7 @@ def with_token_uri(self, token_uri): client_secret=self.client_secret, scopes=self.scopes, default_scopes=self.default_scopes, + granted_scopes=self.granted_scopes, quota_project_id=self.quota_project_id, rapt_token=self.rapt_token, enable_reauth_refresh=self._enable_reauth_refresh, @@ -335,10 +351,15 @@ def refresh(self, request): if scopes and "scope" in grant_response: requested_scopes = frozenset(scopes) - granted_scopes = frozenset(grant_response["scope"].split()) + self._granted_scopes = grant_response["scope"].split() + granted_scopes = frozenset(self._granted_scopes) scopes_requested_but_not_granted = requested_scopes - granted_scopes if scopes_requested_but_not_granted: - raise exceptions.RefreshError( + # User might be presented with unbundled scopes at the time of + # consent. So it is a valid scenario to not have all the requested + # scopes as part of granted scopes but log a warning in case the + # developer wants to debug the scenario. + _LOGGER.warning( "Not all requested scopes were granted by the " "authorization server, missing scopes {}.".format( ", ".join(scopes_requested_but_not_granted) diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index 88f2e040e..592f52183 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -23,11 +23,13 @@ """ import os +import pathlib import subprocess +import shutil +import tempfile from nox.command import which import nox -import py.path HERE = os.path.abspath(os.path.dirname(__file__)) LIBRARY_DIR = os.path.abspath(os.path.dirname(HERE)) @@ -59,16 +61,18 @@ CLOUD_SDK_ROOT = os.environ.get("CLOUD_SDK_ROOT") if CLOUD_SDK_ROOT is not None: - CLOUD_SDK_ROOT = py.path.local(CLOUD_SDK_ROOT) - CLOUD_SDK_ROOT.ensure(dir=True) # Makes sure the directory exists. + CLOUD_SDK_ROOT = pathlib.Path(CLOUD_SDK_ROOT) + if not CLOUD_SDK_ROOT.exists() or not CLOUD_SDK_ROOT.is_dir(): + print("{} did not exist! Please set the CLOUD_SDK_ROOT environment variable to a directory that exists".format(CLOUD_SDK_ROOT)) + exit(1) else: - CLOUD_SDK_ROOT = py.path.local.mkdtemp() + CLOUD_SDK_ROOT = pathlib.Path(tempfile.mkdtemp()) # The full path the cloud sdk install directory -CLOUD_SDK_INSTALL_DIR = CLOUD_SDK_ROOT.join("google-cloud-sdk") +CLOUD_SDK_INSTALL_DIR = CLOUD_SDK_ROOT.joinpath("google-cloud-sdk") # The full path to the gcloud cli executable. -GCLOUD = str(CLOUD_SDK_INSTALL_DIR.join("bin", "gcloud")) +GCLOUD = str(CLOUD_SDK_INSTALL_DIR.joinpath("bin", "gcloud")) # gcloud requires Python 2 and doesn't work on 3, so we need to tell it # where to find 2 when we're running in a 3 environment. @@ -90,26 +94,26 @@ def install_cloud_sdk(session): # This set the $PATH for the subprocesses so they can find the gcloud # executable. session.env["PATH"] = ( - str(CLOUD_SDK_INSTALL_DIR.join("bin")) + os.pathsep + os.environ["PATH"] + str(CLOUD_SDK_INSTALL_DIR.joinpath("bin")) + os.pathsep + os.environ["PATH"] ) # If gcloud cli executable already exists, just update it. - if py.path.local(GCLOUD).exists(): + if pathlib.Path(GCLOUD).exists(): session.run(GCLOUD, "components", "update", "-q") return - tar_path = CLOUD_SDK_ROOT.join(CLOUD_SDK_DIST_FILENAME) + tar_path = CLOUD_SDK_ROOT.joinpath(CLOUD_SDK_DIST_FILENAME) # Download the release. session.run("wget", CLOUD_SDK_DOWNLOAD_URL, "-O", str(tar_path), silent=True) # Extract the release. session.run("tar", "xzf", str(tar_path), "-C", str(CLOUD_SDK_ROOT)) - session.run(tar_path.remove) + tar_path.unlink() # Run the install script. session.run( - str(CLOUD_SDK_INSTALL_DIR.join("install.sh")), + str(CLOUD_SDK_INSTALL_DIR.joinpath("install.sh")), "--usage-reporting", "false", "--path-update", @@ -123,10 +127,10 @@ def install_cloud_sdk(session): def copy_credentials(credentials_path): """Copies credentials into the SDK root as the application default credentials.""" - dest = CLOUD_SDK_ROOT.join("application_default_credentials.json") + dest = CLOUD_SDK_ROOT.joinpath("application_default_credentials.json") if dest.exists(): - dest.remove() - py.path.local(credentials_path).copy(dest) + dest.unlink() + shutil.copyfile(pathlib.Path(credentials_path), dest) def configure_cloud_sdk(session, application_default_credentials, project=False): diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 99e8d8b82..6ad7ea014 100644 Binary files a/system_tests/secrets.tar.enc and b/system_tests/secrets.tar.enc differ diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index c8301078d..5a63224fd 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -449,6 +449,7 @@ def test_credentials_with_scopes_requested_refresh_success( assert creds.id_token == mock.sentinel.id_token assert creds.has_scopes(scopes) assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == scopes # Check that the credentials are valid (have a token and are not # expired.) @@ -466,7 +467,7 @@ def test_credentials_with_only_default_scopes_requested( token = "token" new_rapt_token = "new_rapt_token" expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) - grant_response = {"id_token": mock.sentinel.id_token} + grant_response = {"id_token": mock.sentinel.id_token, "scope": "email profile"} refresh_grant.return_value = ( # Access token token, @@ -513,6 +514,7 @@ def test_credentials_with_only_default_scopes_requested( assert creds.id_token == mock.sentinel.id_token assert creds.has_scopes(default_scopes) assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == default_scopes # Check that the credentials are valid (have a token and are not # expired.) @@ -530,10 +532,7 @@ def test_credentials_with_scopes_returned_refresh_success( token = "token" new_rapt_token = "new_rapt_token" expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) - grant_response = { - "id_token": mock.sentinel.id_token, - "scopes": " ".join(scopes), - } + grant_response = {"id_token": mock.sentinel.id_token, "scope": " ".join(scopes)} refresh_grant.return_value = ( # Access token token, @@ -580,6 +579,7 @@ def test_credentials_with_scopes_returned_refresh_success( assert creds.id_token == mock.sentinel.id_token assert creds.has_scopes(scopes) assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == scopes # Check that the credentials are valid (have a token and are not # expired.) @@ -590,7 +590,72 @@ def test_credentials_with_scopes_returned_refresh_success( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, ) - def test_credentials_with_scopes_refresh_failure_raises_refresh_error( + def test_credentials_with_only_default_scopes_requested_different_granted_scopes( + self, unused_utcnow, refresh_grant + ): + default_scopes = ["email", "profile"] + token = "token" + new_rapt_token = "new_rapt_token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token, "scope": "email"} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + # rapt token + new_rapt_token, + ) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, + refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + default_scopes=default_scopes, + rapt_token=self.RAPT_TOKEN, + enable_reauth_refresh=True, + ) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, + self.TOKEN_URI, + self.REFRESH_TOKEN, + self.CLIENT_ID, + self.CLIENT_SECRET, + default_scopes, + self.RAPT_TOKEN, + True, + ) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(default_scopes) + assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == ["email"] + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch("google.oauth2.reauth.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD, + ) + def test_credentials_with_scopes_refresh_different_granted_scopes( self, unused_utcnow, refresh_grant ): scopes = ["email", "profile"] @@ -628,10 +693,7 @@ def test_credentials_with_scopes_refresh_failure_raises_refresh_error( ) # Refresh credentials - with pytest.raises( - exceptions.RefreshError, match="Not all requested scopes were granted" - ): - creds.refresh(request) + creds.refresh(request) # Check jwt grant call. refresh_grant.assert_called_with( @@ -651,6 +713,7 @@ def test_credentials_with_scopes_refresh_failure_raises_refresh_error( assert creds.id_token == mock.sentinel.id_token assert creds.has_scopes(scopes) assert creds.rapt_token == new_rapt_token + assert creds.granted_scopes == scopes_returned # Check that the credentials are valid (have a token and are not # expired.) diff --git a/tests/test__default.py b/tests/test__default.py index 7f19656b9..b10fb192c 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -19,6 +19,7 @@ import pytest # type: ignore from google.auth import _default +from google.auth import api_key from google.auth import app_engine from google.auth import aws from google.auth import compute_engine @@ -683,6 +684,12 @@ def test__get_gdch_service_account_credentials_invalid_format_version(): assert excinfo.match("Failed to load GDCH service account credentials") +def test_get_api_key_credentials(): + creds = _default.get_api_key_credentials("api_key") + assert isinstance(creds, api_key.Credentials) + assert creds.token == "api_key" + + class _AppIdentityModule(object): """The interface of the App Idenity app engine module. See https://cloud.google.com/appengine/docs/standard/python/refdocs\ diff --git a/tests/test_api_key.py b/tests/test_api_key.py new file mode 100644 index 000000000..9ba7b1426 --- /dev/null +++ b/tests/test_api_key.py @@ -0,0 +1,45 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest # type: ignore + +from google.auth import api_key + + +def test_credentials_constructor(): + with pytest.raises(ValueError) as excinfo: + api_key.Credentials("") + + assert excinfo.match(r"Token must be a non-empty API key string") + + +def test_expired_and_valid(): + credentials = api_key.Credentials("api-key") + + assert credentials.valid + assert credentials.token == "api-key" + assert not credentials.expired + + credentials.refresh(None) + assert credentials.valid + assert credentials.token == "api-key" + assert not credentials.expired + + +def test_before_request(): + credentials = api_key.Credentials("api-key") + headers = {} + + credentials.before_request(None, "http://example.com", "GET", headers) + assert headers["x-goog-api-key"] == "api-key" diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 18ac75511..78a272b6a 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -67,23 +67,31 @@ VALID_TOKEN_URLS = [ "https://sts.googleapis.com", + "https://sts.mtls.googleapis.com", "https://us-east-1.sts.googleapis.com", + "https://us-east-1.sts.mtls.googleapis.com", "https://US-EAST-1.sts.googleapis.com", "https://sts.us-east-1.googleapis.com", "https://sts.US-WEST-1.googleapis.com", "https://us-east-1-sts.googleapis.com", "https://US-WEST-1-sts.googleapis.com", + "https://US-WEST-1-sts.mtls.googleapis.com", "https://us-west-1-sts.googleapis.com/path?query", "https://sts-us-east-1.p.googleapis.com", + "https://sts-us-east-1.p.mtls.googleapis.com", ] INVALID_TOKEN_URLS = [ "https://iamcredentials.googleapis.com", + "https://mtls.iamcredentials.googleapis.com", "sts.googleapis.com", + "mtls.sts.googleapis.com", + "mtls.googleapis.com", "https://", "http://sts.googleapis.com", "https://st.s.googleapis.com", "https://us-eas\t-1.sts.googleapis.com", "https:/us-east-1.sts.googleapis.com", + "https:/us-east-1.mtls.sts.googleapis.com", "https://US-WE/ST-1-sts.googleapis.com", "https://sts-us-east-1.googleapis.com", "https://sts-US-WEST-1.googleapis.com", @@ -95,16 +103,20 @@ "hhttps://us-east-1.sts.googleapis.com", "https://us- -1.sts.googleapis.com", "https://-sts.googleapis.com", + "https://-mtls.googleapis.com", "https://us-east-1.sts.googleapis.com.evil.com", "https://sts.pgoogleapis.com", "https://p.googleapis.com", "https://sts.p.com", + "https://sts.p.mtls.com", "http://sts.p.googleapis.com", "https://xyz-sts.p.googleapis.com", "https://sts-xyz.123.p.googleapis.com", "https://sts-xyz.p1.googleapis.com", "https://sts-xyz.p.foo.com", "https://sts-xyz.p.foo.googleapis.com", + "https://sts-xyz.mtls.p.foo.googleapis.com", + "https://sts-xyz.p.mtls.foo.googleapis.com", ] VALID_SERVICE_ACCOUNT_IMPERSONATION_URLS = [ "https://iamcredentials.googleapis.com", diff --git a/tests/test_jwt.py b/tests/test_jwt.py index bc01ebfc7..d45d5daa9 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -126,6 +126,29 @@ def test_decode_valid(token_factory): assert payload["metadata"]["meta"] == "data" +def test_decode_header_object(token_factory): + payload = token_factory() + # Create a malformed JWT token with a number as a header instead of a + # dictionary (3 == base64d(M7==)) + payload = b"M7." + b".".join(payload.split(b".")[1:]) + + with pytest.raises(ValueError) as excinfo: + jwt.decode(payload, certs=PUBLIC_CERT_BYTES) + assert excinfo.match(r"Header segment should be a JSON object: " + str(b"M7")) + + +def test_decode_payload_object(signer): + # Create a malformed JWT token with a payload containing both "iat" and + # "exp" strings, although not as fields of a dictionary + payload = jwt.encode(signer, "iatexp") + + with pytest.raises(ValueError) as excinfo: + jwt.decode(payload, certs=PUBLIC_CERT_BYTES) + assert excinfo.match( + r"Payload segment should be a JSON object: " + str(b"ImlhdGV4cCI") + ) + + def test_decode_valid_es256(token_factory): payload = jwt.decode( token_factory(use_es256_signer=True), certs=EC_PUBLIC_CERT_BYTES diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 0c0ebeb06..cd553da83 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -232,6 +232,21 @@ def make_pluggable( interactive=interactive, ) + def test_from_constructor_and_injection(self): + credentials = pluggable.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + token_info_url=TOKEN_INFO_URL, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + setattr(credentials, "_tokeninfo_username", "mock_external_account_id") + + assert isinstance(credentials, pluggable.Credentials) + assert credentials.interactive + assert credentials.external_account_id == "mock_external_account_id" + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) def test_from_info_full_options(self, mock_init): credentials = pluggable.Credentials.from_info( @@ -1064,23 +1079,6 @@ def test_credential_source_timeout_large(self): assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_credential_source_interactive_timeout_missing_will_use_default_interactive_timeout_value( - self - ): - CREDENTIAL_SOURCE = { - "executable": { - "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, - "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, - } - } - credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) - - assert ( - credentials._credential_source_executable_interactive_timeout_millis - == pluggable.EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT - ) - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_credential_source_interactive_timeout_small(self): with pytest.raises(ValueError) as excinfo: @@ -1093,7 +1091,9 @@ def test_credential_source_interactive_timeout_small(self): } _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) - assert excinfo.match(r"Interactive timeout must be between 5 and 30 minutes.") + assert excinfo.match( + r"Interactive timeout must be between 30 seconds and 30 minutes." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_credential_source_interactive_timeout_large(self): @@ -1107,7 +1107,9 @@ def test_credential_source_interactive_timeout_large(self): } _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) - assert excinfo.match(r"Interactive timeout must be between 5 and 30 minutes.") + assert excinfo.match( + r"Interactive timeout must be between 30 seconds and 30 minutes." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_executable_fail(self): @@ -1136,6 +1138,25 @@ def test_retrieve_subject_token_non_workforce_fail_interactive_mode(self): assert excinfo.match(r"Interactive mode is only enabled for workforce pool.") + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_fail_on_validation_missing_interactive_timeout( + self + ): + CREDENTIAL_SOURCE_EXECUTABLE = { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} + credentials = self.make_pluggable( + credential_source=CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Interactive mode cannot run without an interactive timeout." + ) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_executable_fail_interactive_mode(self): with mock.patch(