diff --git a/.kokoro/test-samples.sh b/.kokoro/test-samples.sh index 9a9de2086..dbfe9fbb0 100755 --- a/.kokoro/test-samples.sh +++ b/.kokoro/test-samples.sh @@ -87,11 +87,11 @@ for file in samples/**/requirements.txt; do python3.6 -m nox -s "$RUN_TESTS_SESSION" EXIT=$? - # If this is a periodic build, send the test log to the Build Cop Bot. - # See https://github.com/googleapis/repo-automation-bots/tree/master/packages/buildcop. + # If this is a periodic build, send the test log to the FlakyBot. + # See https://github.com/googleapis/repo-automation-bots/tree/master/packages/flakybot. if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then - chmod +x $KOKORO_GFILE_DIR/linux_amd64/buildcop - $KOKORO_GFILE_DIR/linux_amd64/buildcop + chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot + $KOKORO_GFILE_DIR/linux_amd64/flakybot fi if [[ $EXIT -ne 0 ]]; then diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh index 719bcd5ba..4af6cdc26 100755 --- a/.kokoro/trampoline_v2.sh +++ b/.kokoro/trampoline_v2.sh @@ -159,7 +159,7 @@ if [[ -n "${KOKORO_BUILD_ID:-}" ]]; then "KOKORO_GITHUB_COMMIT" "KOKORO_GITHUB_PULL_REQUEST_NUMBER" "KOKORO_GITHUB_PULL_REQUEST_COMMIT" - # For Build Cop Bot + # For FlakyBot "KOKORO_GITHUB_COMMIT_URL" "KOKORO_GITHUB_PULL_REQUEST_URL" ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a06300cf..88bb81b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ [1]: https://pypi.org/project/google-auth/#history +## [1.25.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.24.0...v1.25.0) (2021-02-03) + + +### Features + +* support self-signed jwt in requests and urllib3 transports ([#679](https://www.github.com/googleapis/google-auth-library-python/issues/679)) ([7a94acb](https://www.github.com/googleapis/google-auth-library-python/commit/7a94acb50e75fe0a51688e0f968bca3fa9bd9082)) +* use self-signed jwt for service account ([#665](https://www.github.com/googleapis/google-auth-library-python/issues/665)) ([bf5ce0c](https://www.github.com/googleapis/google-auth-library-python/commit/bf5ce0c56c10f655ced6630653f0f2ad47fcceeb)) + ## [1.24.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.23.0...v1.24.0) (2020-12-11) diff --git a/google/auth/_default.py b/google/auth/_default.py index acf76e924..9880c9343 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -71,7 +71,7 @@ def _warn_about_problematic_credentials(credentials): def load_credentials_from_file( - filename, scopes=None, quota_project_id=None, request=None + filename, scopes=None, default_scopes=None, quota_project_id=None, request=None ): """Loads Google credentials from a file. @@ -83,6 +83,8 @@ def load_credentials_from_file( scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If specified, the credentials will automatically be scoped if necessary + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. quota_project_id (Optional[str]): The project ID used for quota and billing. request (Optional[google.auth.transport.Request]): An object used to make @@ -141,7 +143,7 @@ def load_credentials_from_file( try: credentials = service_account.Credentials.from_service_account_info( - info, scopes=scopes + info, scopes=scopes, default_scopes=default_scopes ) except ValueError as caught_exc: msg = "Failed to load service account credentials from {}".format(filename) @@ -153,7 +155,11 @@ def load_credentials_from_file( elif credential_type == _EXTERNAL_ACCOUNT_TYPE: credentials, project_id = _get_external_account_credentials( - info, filename, scopes=scopes, request=request + info, + filename, + scopes=scopes, + default_scopes=default_scopes, + request=request, ) if quota_project_id: credentials = credentials.with_quota_project(quota_project_id) @@ -189,7 +195,7 @@ def _get_gcloud_sdk_credentials(): return credentials, project_id -def _get_explicit_environ_credentials(request=None, scopes=None): +def _get_explicit_environ_credentials(request=None, scopes=None, default_scopes=None): """Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment variable. @@ -202,6 +208,8 @@ def _get_explicit_environ_credentials(request=None, scopes=None): scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If specified, the credentials will automatically be scoped if necessary. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. Returns: Tuple[Optional[google.auth.credentials.Credentials], Optional[str]]: Loaded @@ -217,7 +225,11 @@ def _get_explicit_environ_credentials(request=None, scopes=None): if explicit_file is not None: credentials, project_id = load_credentials_from_file( - os.environ[environment_vars.CREDENTIALS], request=request, scopes=scopes + os.environ[environment_vars.CREDENTIALS], + scopes=scopes, + default_scopes=default_scopes, + quota_project_id=None, + request=request, ) return credentials, project_id @@ -282,7 +294,9 @@ def _get_gce_credentials(request=None): return None, None -def _get_external_account_credentials(info, filename, scopes=None, request=None): +def _get_external_account_credentials( + info, filename, scopes=None, default_scopes=None, request=None +): """Loads external account Credentials from the parsed external account info. The credentials information must correspond to a supported external account @@ -294,6 +308,8 @@ def _get_external_account_credentials(info, filename, scopes=None, request=None) scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If specified, the credentials will automatically be scoped if necessary. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. request (Optional[google.auth.transport.Request]): An object used to make HTTP requests. This is used to determine the associated project ID for a workload identity pool resource (external account credentials). @@ -314,13 +330,17 @@ def _get_external_account_credentials(info, filename, scopes=None, request=None) # Check if configuration corresponds to an AWS credentials. from google.auth import aws - credentials = aws.Credentials.from_info(info, scopes=scopes) + credentials = aws.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) except ValueError: try: # Check if configuration corresponds to an Identity Pool credentials. from google.auth import identity_pool - credentials = identity_pool.Credentials.from_info(info, scopes=scopes) + credentials = identity_pool.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) except ValueError: # If the configuration is invalid or does not correspond to any # supported external_account credentials, raise an error. @@ -333,7 +353,7 @@ def _get_external_account_credentials(info, filename, scopes=None, request=None) return credentials, credentials.get_project_id(request=request) -def default(scopes=None, request=None, quota_project_id=None): +def default(scopes=None, request=None, quota_project_id=None, default_scopes=None): """Gets the default credentials for the current environment. `Application Default Credentials`_ provides an easy way to obtain @@ -410,6 +430,8 @@ def default(scopes=None, request=None, quota_project_id=None): account credentials. quota_project_id (Optional[str]): The project ID used for quota and billing. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. Returns: Tuple[~google.auth.credentials.Credentials, Optional[str]]: the current environment's credentials and project ID. Project ID @@ -428,7 +450,9 @@ def default(scopes=None, request=None, quota_project_id=None): ) checkers = ( - lambda: _get_explicit_environ_credentials(request=request, scopes=scopes), + lambda: _get_explicit_environ_credentials( + request=request, scopes=scopes, default_scopes=default_scopes + ), _get_gcloud_sdk_credentials, _get_gae_credentials, lambda: _get_gce_credentials(request), @@ -437,7 +461,9 @@ def default(scopes=None, request=None, quota_project_id=None): for checker in checkers: credentials, project_id = checker() if credentials is not None: - credentials = with_scopes_if_required(credentials, scopes) + credentials = with_scopes_if_required( + credentials, scopes, default_scopes=default_scopes + ) if quota_project_id: credentials = credentials.with_quota_project(quota_project_id) diff --git a/google/auth/app_engine.py b/google/auth/app_engine.py index f1d21280e..81aef73b4 100644 --- a/google/auth/app_engine.py +++ b/google/auth/app_engine.py @@ -86,11 +86,19 @@ class Credentials( tokens. """ - def __init__(self, scopes=None, service_account_id=None, quota_project_id=None): + def __init__( + self, + scopes=None, + default_scopes=None, + service_account_id=None, + quota_project_id=None, + ): """ Args: scopes (Sequence[str]): Scopes to request from the App Identity API. + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. service_account_id (str): The service account ID passed into :func:`google.appengine.api.app_identity.get_access_token`. If not specified, the default application service account @@ -109,16 +117,16 @@ def __init__(self, scopes=None, service_account_id=None, quota_project_id=None): super(Credentials, self).__init__() self._scopes = scopes + self._default_scopes = default_scopes self._service_account_id = service_account_id self._signer = Signer() self._quota_project_id = quota_project_id @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): + scopes = self._scopes if self._scopes is not None else self._default_scopes # pylint: disable=unused-argument - token, ttl = app_identity.get_access_token( - self._scopes, self._service_account_id - ) + token, ttl = app_identity.get_access_token(scopes, self._service_account_id) expiry = datetime.datetime.utcfromtimestamp(ttl) self.token, self.expiry = token, expiry @@ -137,12 +145,13 @@ def requires_scopes(self): Returns: bool: True if there are no scopes set otherwise False. """ - return not self._scopes + return not self._scopes and not self._default_scopes @_helpers.copy_docstring(credentials.Scoped) - def with_scopes(self, scopes): + def with_scopes(self, scopes, default_scopes=None): return self.__class__( scopes=scopes, + default_scopes=default_scopes, service_account_id=self._service_account_id, quota_project_id=self.quota_project_id, ) diff --git a/google/auth/aws.py b/google/auth/aws.py index 42e21df70..d11b6b7f9 100644 --- a/google/auth/aws.py +++ b/google/auth/aws.py @@ -345,6 +345,7 @@ def __init__( client_secret=None, quota_project_id=None, scopes=None, + default_scopes=None, ): """Instantiates an AWS workload external account credentials object. @@ -362,6 +363,8 @@ def __init__( quota_project_id (Optional[str]): The optional quota project ID. scopes (Optional[Sequence[str]]): Optional scopes to request during the authorization grant. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. Raises: google.auth.exceptions.RefreshError: If an error is encountered during @@ -382,6 +385,7 @@ def __init__( client_secret=client_secret, quota_project_id=quota_project_id, scopes=scopes, + default_scopes=default_scopes, ) credential_source = credential_source or {} self._environment_id = credential_source.get("environment_id") or "" diff --git a/google/auth/compute_engine/credentials.py b/google/auth/compute_engine/credentials.py index 29063103a..167165620 100644 --- a/google/auth/compute_engine/credentials.py +++ b/google/auth/compute_engine/credentials.py @@ -52,7 +52,11 @@ class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject): """ def __init__( - self, service_account_email="default", quota_project_id=None, scopes=None + self, + service_account_email="default", + quota_project_id=None, + scopes=None, + default_scopes=None, ): """ Args: @@ -61,11 +65,15 @@ def __init__( accounts. quota_project_id (Optional[str]): The project ID used for quota and billing. + scopes (Optional[Sequence[str]]): The list of scopes for the credentials. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. """ super(Credentials, self).__init__() self._service_account_email = service_account_email self._quota_project_id = quota_project_id self._scopes = scopes + self._default_scopes = default_scopes def _retrieve_info(self, request): """Retrieve information about the service account. @@ -98,12 +106,11 @@ def refresh(self, request): service can't be reached if if the instance has not credentials. """ + scopes = self._scopes if self._scopes is not None else self._default_scopes try: self._retrieve_info(request) self.token, self.expiry = _metadata.get_service_account_token( - request, - service_account=self._service_account_email, - scopes=self._scopes, + request, service_account=self._service_account_email, scopes=scopes ) except exceptions.TransportError as caught_exc: new_exc = exceptions.RefreshError(caught_exc) @@ -131,12 +138,13 @@ def with_quota_project(self, quota_project_id): ) @_helpers.copy_docstring(credentials.Scoped) - def with_scopes(self, scopes): + def with_scopes(self, scopes, default_scopes=None): # Compute Engine credentials can not be scoped (the metadata service # ignores the scopes parameter). App Engine, Cloud Run and Flex support # requesting scopes. return self.__class__( scopes=scopes, + default_scopes=default_scopes, service_account_email=self._service_account_email, quota_project_id=self._quota_project_id, ) diff --git a/google/auth/credentials.py b/google/auth/credentials.py index 02082cad9..7d3c798b1 100644 --- a/google/auth/credentials.py +++ b/google/auth/credentials.py @@ -220,12 +220,18 @@ class ReadOnlyScoped(object): def __init__(self): super(ReadOnlyScoped, self).__init__() self._scopes = None + self._default_scopes = None @property def scopes(self): """Sequence[str]: the credentials' current set of scopes.""" return self._scopes + @property + def default_scopes(self): + """Sequence[str]: the credentials' current set of default scopes.""" + return self._default_scopes + @abc.abstractproperty def requires_scopes(self): """True if these credentials require scopes to obtain an access token. @@ -244,7 +250,10 @@ def has_scopes(self, scopes): Returns: bool: True if the credentials have the given scopes. """ - return set(scopes).issubset(set(self._scopes or [])) + credential_scopes = ( + self._scopes if self._scopes is not None else self._default_scopes + ) + return set(scopes).issubset(set(credential_scopes or [])) class Scoped(ReadOnlyScoped): @@ -277,7 +286,7 @@ class Scoped(ReadOnlyScoped): """ @abc.abstractmethod - def with_scopes(self, scopes): + def with_scopes(self, scopes, default_scopes=None): """Create a copy of these credentials with the specified scopes. Args: @@ -292,7 +301,7 @@ def with_scopes(self, scopes): raise NotImplementedError("This class does not require scoping.") -def with_scopes_if_required(credentials, scopes): +def with_scopes_if_required(credentials, scopes, default_scopes=None): """Creates a copy of the credentials with scopes if scoping is required. This helper function is useful when you do not know (or care to know) the @@ -306,6 +315,8 @@ def with_scopes_if_required(credentials, scopes): credentials (google.auth.credentials.Credentials): The credentials to scope if necessary. scopes (Sequence[str]): The list of scopes to use. + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. Returns: google.auth.credentials.Credentials: Either a new set of scoped @@ -313,7 +324,7 @@ def with_scopes_if_required(credentials, scopes): was required. """ if isinstance(credentials, Scoped) and credentials.requires_scopes: - return credentials.with_scopes(scopes) + return credentials.with_scopes(scopes, default_scopes=default_scopes) else: return credentials diff --git a/google/auth/external_account.py b/google/auth/external_account.py index cf9cdf39b..85fee64db 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -68,6 +68,7 @@ def __init__( client_secret=None, quota_project_id=None, scopes=None, + default_scopes=None, ): """Instantiates an external account credentials object. @@ -83,6 +84,8 @@ def __init__( quota_project_id (Optional[str]): The optional quota project ID. scopes (Optional[Sequence[str]]): Optional scopes to request during the authorization grant. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. Raises: google.auth.exceptions.RefreshError: If the generateAccessToken endpoint returned an error. @@ -97,6 +100,7 @@ def __init__( self._client_secret = client_secret self._quota_project_id = quota_project_id self._scopes = scopes + self._default_scopes = default_scopes if self._client_id: self._client_auth = utils.ClientAuthentication( @@ -119,7 +123,7 @@ def requires_scopes(self): Returns: bool: True if there are no scopes set otherwise False. """ - return True if not self._scopes else False + return not self._scopes and not self._default_scopes @property def project_number(self): @@ -136,7 +140,7 @@ def project_number(self): return None @_helpers.copy_docstring(credentials.Scoped) - def with_scopes(self, scopes): + def with_scopes(self, scopes, default_scopes=None): return self.__class__( audience=self._audience, subject_token_type=self._subject_token_type, @@ -147,6 +151,7 @@ def with_scopes(self, scopes): client_secret=self._client_secret, quota_project_id=self._quota_project_id, scopes=scopes, + default_scopes=default_scopes, ) @abc.abstractmethod @@ -186,8 +191,9 @@ def get_project_id(self, request): if self._project_id: # If already retrieved, return the cached project ID value. return self._project_id + scopes = self._scopes if self._scopes is not None else self._default_scopes # Scopes are required in order to retrieve a valid access token. - if self.project_number and self._scopes: + if self.project_number and scopes: headers = {} url = _CLOUD_RESOURCE_MANAGER + self.project_number self.before_request(request, "GET", url, headers) @@ -209,6 +215,7 @@ def get_project_id(self, request): @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): + scopes = self._scopes if self._scopes is not None else self._default_scopes if self._impersonated_credentials: self._impersonated_credentials.refresh(request) self.token = self._impersonated_credentials.token @@ -221,7 +228,7 @@ def refresh(self, request): subject_token=self.retrieve_subject_token(request), subject_token_type=self._subject_token_type, audience=self._audience, - scopes=self._scopes, + scopes=scopes, requested_token_type=_STS_REQUESTED_TOKEN_TYPE, ) self.token = response_data.get("access_token") @@ -241,6 +248,7 @@ def with_quota_project(self, quota_project_id): client_secret=self._client_secret, quota_project_id=quota_project_id, scopes=self._scopes, + default_scopes=self._default_scopes, ) def _initialize_impersonated_credentials(self): @@ -269,6 +277,7 @@ def _initialize_impersonated_credentials(self): client_secret=self._client_secret, quota_project_id=self._quota_project_id, scopes=self._scopes, + default_scopes=self._default_scopes, ) # Determine target_principal. @@ -284,11 +293,12 @@ def _initialize_impersonated_credentials(self): "Unable to determine target principal from service account impersonation URL." ) + scopes = self._scopes if self._scopes is not None else self._default_scopes # Initialize and return impersonated credentials. return impersonated_credentials.Credentials( source_credentials=source_credentials, target_principal=target_principal, - target_scopes=self._scopes, + target_scopes=scopes, quota_project_id=self._quota_project_id, iam_endpoint_override=self._service_account_impersonation_url, ) diff --git a/google/auth/identity_pool.py b/google/auth/identity_pool.py index 5eed7a77c..e19151a0f 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -56,6 +56,7 @@ def __init__( client_secret=None, quota_project_id=None, scopes=None, + default_scopes=None, ): """Instantiates an external account credentials object from a file/url. @@ -85,6 +86,8 @@ def __init__( quota_project_id (Optional[str]): The optional quota project ID. scopes (Optional[Sequence[str]]): Optional scopes to request during the authorization grant. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. Raises: google.auth.exceptions.RefreshError: If an error is encountered during @@ -106,6 +109,7 @@ def __init__( client_secret=client_secret, quota_project_id=quota_project_id, scopes=scopes, + default_scopes=default_scopes, ) if not isinstance(credential_source, Mapping): self._credential_source_file = None diff --git a/google/auth/transport/grpc.py b/google/auth/transport/grpc.py index ab7d0dbf8..04c0f4f55 100644 --- a/google/auth/transport/grpc.py +++ b/google/auth/transport/grpc.py @@ -24,6 +24,7 @@ from google.auth import environment_vars from google.auth import exceptions from google.auth.transport import _mtls_helper +from google.oauth2 import service_account try: import grpc @@ -51,15 +52,19 @@ class AuthMetadataPlugin(grpc.AuthMetadataPlugin): add to requests. request (google.auth.transport.Request): A HTTP transport request object used to refresh credentials as needed. + default_host (Optional[str]): A host like "pubsub.googleapis.com". + This is used when a self-signed JWT is created from service + account credentials. """ - def __init__(self, credentials, request): + def __init__(self, credentials, request, default_host=None): # pylint: disable=no-value-for-parameter # pylint doesn't realize that the super method takes no arguments # because this class is the same name as the superclass. super(AuthMetadataPlugin, self).__init__() self._credentials = credentials self._request = request + self._default_host = default_host def _get_authorization_headers(self, context): """Gets the authorization headers for a request. @@ -69,6 +74,19 @@ def _get_authorization_headers(self, context): to add to the request. """ headers = {} + + # https://google.aip.dev/auth/4111 + # Attempt to use self-signed JWTs when a service account is used. + # A default host must be explicitly provided since it cannot always + # be determined from the context.service_url. + if ( + isinstance(self._credentials, service_account.Credentials) + and self._default_host + ): + self._credentials._create_self_signed_jwt( + "https://{}/".format(self._default_host) + ) + self._credentials.before_request( self._request, context.method_name, context.service_url, headers ) diff --git a/google/auth/transport/requests.py b/google/auth/transport/requests.py index 9a2f3afc7..ef973fce4 100644 --- a/google/auth/transport/requests.py +++ b/google/auth/transport/requests.py @@ -45,6 +45,7 @@ from google.auth import exceptions from google.auth import transport import google.auth.transport._mtls_helper +from google.oauth2 import service_account _LOGGER = logging.getLogger(__name__) @@ -313,6 +314,9 @@ def my_cert_callback(): refreshing credentials. If not passed, an instance of :class:`~google.auth.transport.requests.Request` is created. + default_host (Optional[str]): A host like "pubsub.googleapis.com". + This is used when a self-signed JWT is created from service + account credentials. """ def __init__( @@ -322,6 +326,7 @@ def __init__( max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS, refresh_timeout=None, auth_request=None, + default_host=None, ): super(AuthorizedSession, self).__init__() self.credentials = credentials @@ -329,6 +334,7 @@ def __init__( self._max_refresh_attempts = max_refresh_attempts self._refresh_timeout = refresh_timeout self._is_mtls = False + self._default_host = default_host if auth_request is None: auth_request_session = requests.Session() @@ -347,6 +353,17 @@ def __init__( # credentials.refresh). self._auth_request = auth_request + # https://google.aip.dev/auth/4111 + # Attempt to use self-signed JWTs when a service account is used. + # A default host must be explicitly provided. + if ( + isinstance(self.credentials, service_account.Credentials) + and self._default_host + ): + self.credentials._create_self_signed_jwt( + "https://{}/".format(self._default_host) + ) + def configure_mtls_channel(self, client_cert_callback=None): """Configure the client certificate and key for SSL connection. diff --git a/google/auth/transport/urllib3.py b/google/auth/transport/urllib3.py index 209fc51bc..aadd116e8 100644 --- a/google/auth/transport/urllib3.py +++ b/google/auth/transport/urllib3.py @@ -49,6 +49,7 @@ from google.auth import environment_vars from google.auth import exceptions from google.auth import transport +from google.oauth2 import service_account _LOGGER = logging.getLogger(__name__) @@ -262,6 +263,9 @@ def my_cert_callback(): retried. max_refresh_attempts (int): The maximum number of times to attempt to refresh the credentials and retry the request. + default_host (Optional[str]): A host like "pubsub.googleapis.com". + This is used when a self-signed JWT is created from service + account credentials. """ def __init__( @@ -270,6 +274,7 @@ def __init__( http=None, refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS, + default_host=None, ): if http is None: self.http = _make_default_http() @@ -281,10 +286,22 @@ def __init__( self.credentials = credentials self._refresh_status_codes = refresh_status_codes self._max_refresh_attempts = max_refresh_attempts + self._default_host = default_host # Request instance used by internal methods (for example, # credentials.refresh). self._request = Request(self.http) + # https://google.aip.dev/auth/4111 + # Attempt to use self-signed JWTs when a service account is used. + # A default host must be explicitly provided. + if ( + isinstance(self.credentials, service_account.Credentials) + and self._default_host + ): + self.credentials._create_self_signed_jwt( + "https://{}/".format(self._default_host) + ) + super(AuthorizedHttp, self).__init__() def configure_mtls_channel(self, client_cert_callback=None): diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 36b8f0cb7..464cc4878 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -66,6 +66,7 @@ def __init__( client_id=None, client_secret=None, scopes=None, + default_scopes=None, quota_project_id=None, expiry=None, ): @@ -91,6 +92,8 @@ def __init__( token if refresh information is provided (e.g. The refresh token scopes are a superset of this or contain a wild card scope like 'https://www.googleapis.com/auth/any-api'). + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. quota_project_id (Optional[str]): The project ID used for quota and billing. This project may be different from the project used to create the credentials. @@ -101,6 +104,7 @@ def __init__( self._refresh_token = refresh_token self._id_token = id_token self._scopes = scopes + self._default_scopes = default_scopes self._token_uri = token_uri self._client_id = client_id self._client_secret = client_secret @@ -121,6 +125,7 @@ def __setstate__(self, d): self._refresh_token = d.get("_refresh_token") self._id_token = d.get("_id_token") self._scopes = d.get("_scopes") + self._default_scopes = d.get("_default_scopes") self._token_uri = d.get("_token_uri") self._client_id = d.get("_client_id") self._client_secret = d.get("_client_secret") @@ -180,6 +185,7 @@ def with_quota_project(self, quota_project_id): client_id=self.client_id, client_secret=self.client_secret, scopes=self.scopes, + default_scopes=self.default_scopes, quota_project_id=quota_project_id, ) @@ -197,13 +203,15 @@ def refresh(self, request): "token_uri, client_id, and client_secret." ) + scopes = self._scopes if self._scopes is not None else self._default_scopes + access_token, refresh_token, expiry, grant_response = _client.refresh_grant( request, self._token_uri, self._refresh_token, self._client_id, self._client_secret, - self._scopes, + scopes, ) self.token = access_token @@ -211,8 +219,8 @@ def refresh(self, request): self._refresh_token = refresh_token self._id_token = grant_response.get("id_token") - if self._scopes and "scopes" in grant_response: - requested_scopes = frozenset(self._scopes) + if scopes and "scopes" in grant_response: + requested_scopes = frozenset(scopes) granted_scopes = frozenset(grant_response["scopes"].split()) scopes_requested_but_not_granted = requested_scopes - granted_scopes if scopes_requested_but_not_granted: diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index c4898a247..ed9101142 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -126,6 +126,7 @@ def __init__( service_account_email, token_uri, scopes=None, + default_scopes=None, subject=None, project_id=None, quota_project_id=None, @@ -135,8 +136,10 @@ def __init__( Args: signer (google.auth.crypt.Signer): The signer used to sign JWTs. service_account_email (str): The service account's email. - scopes (Sequence[str]): Scopes to request during the authorization - grant. + scopes (Sequence[str]): User-defined scopes to request during the + authorization grant. + default_scopes (Sequence[str]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. token_uri (str): The OAuth 2.0 Token URI. subject (str): For domain-wide delegation, the email address of the user to for which to request delegated access. @@ -155,6 +158,7 @@ def __init__( super(Credentials, self).__init__() self._scopes = scopes + self._default_scopes = default_scopes self._signer = signer self._service_account_email = service_account_email self._subject = subject @@ -162,6 +166,8 @@ def __init__( self._quota_project_id = quota_project_id self._token_uri = token_uri + self._jwt_credentials = None + if additional_claims is not None: self._additional_claims = additional_claims else: @@ -249,11 +255,12 @@ def requires_scopes(self): return True if not self._scopes else False @_helpers.copy_docstring(credentials.Scoped) - def with_scopes(self, scopes): + def with_scopes(self, scopes, default_scopes=None): return self.__class__( self._signer, service_account_email=self._service_account_email, scopes=scopes, + default_scopes=default_scopes, token_uri=self._token_uri, subject=self._subject, project_id=self._project_id, @@ -275,6 +282,7 @@ def with_subject(self, subject): self._signer, service_account_email=self._service_account_email, scopes=self._scopes, + default_scopes=self._default_scopes, token_uri=self._token_uri, subject=subject, project_id=self._project_id, @@ -301,6 +309,7 @@ def with_claims(self, additional_claims): self._signer, service_account_email=self._service_account_email, scopes=self._scopes, + default_scopes=self._default_scopes, token_uri=self._token_uri, subject=self._subject, project_id=self._project_id, @@ -314,6 +323,7 @@ def with_quota_project(self, quota_project_id): return self.__class__( self._signer, service_account_email=self._service_account_email, + default_scopes=self._default_scopes, scopes=self._scopes, token_uri=self._token_uri, subject=self._subject, @@ -357,10 +367,30 @@ def _make_authorization_grant_assertion(self): @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): - assertion = self._make_authorization_grant_assertion() - access_token, expiry, _ = _client.jwt_grant(request, self._token_uri, assertion) - self.token = access_token - self.expiry = expiry + if self._jwt_credentials is not None: + self._jwt_credentials.refresh(request) + self.token = self._jwt_credentials.token + self.expiry = self._jwt_credentials.expiry + else: + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = _client.jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry + + def _create_self_signed_jwt(self, audience): + """Create a self-signed JWT from the credentials if requirements are met. + + Args: + audience (str): The service URL. ``https://[API_ENDPOINT]/`` + """ + # https://google.aip.dev/auth/4111 + # If the user has not defined scopes, create a self-signed jwt + if not self.scopes: + self._jwt_credentials = jwt.Credentials.from_signing_credentials( + self, audience + ) @_helpers.copy_docstring(credentials.Signing) def sign_bytes(self, message): diff --git a/setup.py b/setup.py index 3006d9ace..ba72c3ccf 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.24.0" +version = "1.25.0" setup( name="google-auth", diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index 5d0014bc8..691184e08 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -293,6 +293,21 @@ def grpc(session): session.run("pytest", "system_tests_sync/test_grpc.py") +@nox.session(python=PYTHON_VERSIONS_SYNC) +def requests(session): + session.install(LIBRARY_DIR) + session.install(*TEST_DEPENDENCIES_SYNC) + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + session.run("pytest", "system_tests_sync/test_requests.py") + + +@nox.session(python=PYTHON_VERSIONS_SYNC) +def urllib3(session): + session.install(LIBRARY_DIR) + session.install(*TEST_DEPENDENCIES_SYNC) + session.env[EXPLICIT_CREDENTIALS_ENV] = SERVICE_ACCOUNT_FILE + session.run("pytest", "system_tests_sync/test_urllib3.py") + @nox.session(python=PYTHON_VERSIONS_SYNC) def mtls_http(session): session.install(LIBRARY_DIR) diff --git a/system_tests/system_tests_sync/test_grpc.py b/system_tests/system_tests_sync/test_grpc.py index 7dcbd4c43..7f548ec0e 100644 --- a/system_tests/system_tests_sync/test_grpc.py +++ b/system_tests/system_tests_sync/test_grpc.py @@ -16,14 +16,38 @@ import google.auth.credentials import google.auth.jwt import google.auth.transport.grpc +from google.oauth2 import service_account + from google.cloud import pubsub_v1 def test_grpc_request_with_regular_credentials(http_request): credentials, project_id = google.auth.default() credentials = google.auth.credentials.with_scopes_if_required( - credentials, ["https://www.googleapis.com/auth/pubsub"] + credentials, scopes=["https://www.googleapis.com/auth/pubsub"] + ) + + + # Create a pub/sub client. + client = pubsub_v1.PublisherClient(credentials=credentials) + + # list the topics and drain the iterator to test that an authorized API + # call works. + list_topics_iter = client.list_topics(project="projects/{}".format(project_id)) + list(list_topics_iter) + + +def test_grpc_request_with_regular_credentials_and_self_signed_jwt(http_request): + credentials, project_id = google.auth.default() + + # At the time this test is being written, there are no GAPIC libraries + # that will trigger the self-signed JWT flow. Manually create the self-signed + # jwt on the service account credential to check that the request + # succeeds. + credentials = credentials.with_scopes( + scopes=[], default_scopes=["https://www.googleapis.com/auth/pubsub"] ) + credentials._create_self_signed_jwt(audience="https://pubsub.googleapis.com/") # Create a pub/sub client. client = pubsub_v1.PublisherClient(credentials=credentials) @@ -32,6 +56,10 @@ def test_grpc_request_with_regular_credentials(http_request): # call works. list_topics_iter = client.list_topics(project="projects/{}".format(project_id)) list(list_topics_iter) + + # Check that self-signed JWT was created and is being used + assert credentials._jwt_credentials is not None + assert credentials._jwt_credentials.token == credentials.token def test_grpc_request_with_jwt_credentials(): diff --git a/system_tests/system_tests_sync/test_requests.py b/system_tests/system_tests_sync/test_requests.py new file mode 100644 index 000000000..3ac9179b5 --- /dev/null +++ b/system_tests/system_tests_sync/test_requests.py @@ -0,0 +1,40 @@ +# Copyright 2021 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 google.auth +import google.auth.credentials +import google.auth.transport.requests +from google.oauth2 import service_account + + +def test_authorized_session_with_service_account_and_self_signed_jwt(): + credentials, project_id = google.auth.default() + + credentials = credentials.with_scopes( + scopes=[], + default_scopes=["https://www.googleapis.com/auth/pubsub"], + ) + + session = google.auth.transport.requests.AuthorizedSession( + credentials=credentials, default_host="pubsub.googleapis.com" + ) + + # List Pub/Sub Topics through the REST API + # https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/list + response = session.get("https://pubsub.googleapis.com/v1/projects/{}/topics".format(project_id)) + response.raise_for_status() + + # Check that self-signed JWT was created and is being used + assert credentials._jwt_credentials is not None + assert credentials._jwt_credentials.token == credentials.token diff --git a/system_tests/system_tests_sync/test_urllib3.py b/system_tests/system_tests_sync/test_urllib3.py new file mode 100644 index 000000000..1932e1913 --- /dev/null +++ b/system_tests/system_tests_sync/test_urllib3.py @@ -0,0 +1,44 @@ +# Copyright 2021 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 google.auth +import google.auth.credentials +import google.auth.transport.requests +from google.oauth2 import service_account + + +def test_authorized_session_with_service_account_and_self_signed_jwt(): + credentials, project_id = google.auth.default() + + credentials = credentials.with_scopes( + scopes=[], + default_scopes=["https://www.googleapis.com/auth/pubsub"], + ) + + http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=credentials, default_host="pubsub.googleapis.com" + ) + + # List Pub/Sub Topics through the REST API + # https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/list + response = http.urlopen( + method="GET", + url="https://pubsub.googleapis.com/v1/projects/{}/topics".format(project_id) + ) + + assert response.status == 200 + + # Check that self-signed JWT was created and is being used + assert credentials._jwt_credentials is not None + assert credentials._jwt_credentials.token == credentials.token diff --git a/tests/compute_engine/test__metadata.py b/tests/compute_engine/test__metadata.py index d05337263..852822dc0 100644 --- a/tests/compute_engine/test__metadata.py +++ b/tests/compute_engine/test__metadata.py @@ -318,6 +318,44 @@ def test_get_service_account_token(utcnow): assert expiry == utcnow() + datetime.timedelta(seconds=ttl) +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_get_service_account_token_with_scopes_list(utcnow): + ttl = 500 + request = make_request( + json.dumps({"access_token": "token", "expires_in": ttl}), + headers={"content-type": "application/json"}, + ) + + token, expiry = _metadata.get_service_account_token(request, scopes=["foo", "bar"]) + + request.assert_called_once_with( + method="GET", + url=_metadata._METADATA_ROOT + PATH + "/token" + "?scopes=foo%2Cbar", + headers=_metadata._METADATA_HEADERS, + ) + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=ttl) + + +@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) +def test_get_service_account_token_with_scopes_string(utcnow): + ttl = 500 + request = make_request( + json.dumps({"access_token": "token", "expires_in": ttl}), + headers={"content-type": "application/json"}, + ) + + token, expiry = _metadata.get_service_account_token(request, scopes="foo,bar") + + request.assert_called_once_with( + method="GET", + url=_metadata._METADATA_ROOT + PATH + "/token" + "?scopes=foo%2Cbar", + headers=_metadata._METADATA_HEADERS, + ) + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=ttl) + + def test_get_service_account_info(): key, value = "foo", "bar" request = make_request( diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index ee8b8a211..b885d2973 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -127,6 +127,7 @@ def test_credentials_with_scopes_requested_refresh_success( self, unused_utcnow, refresh_grant ): scopes = ["email", "profile"] + default_scopes = ["https://www.googleapis.com/auth/cloud-platform"] token = "token" expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) grant_response = {"id_token": mock.sentinel.id_token} @@ -149,6 +150,7 @@ def test_credentials_with_scopes_requested_refresh_success( client_id=self.CLIENT_ID, client_secret=self.CLIENT_SECRET, scopes=scopes, + default_scopes=default_scopes, ) # Refresh credentials @@ -174,6 +176,62 @@ def test_credentials_with_scopes_requested_refresh_success( # expired.) assert creds.valid + @mock.patch("google.oauth2._client.refresh_grant", autospec=True) + @mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) + def test_credentials_with_only_default_scopes_requested( + self, unused_utcnow, refresh_grant + ): + default_scopes = ["email", "profile"] + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response, + ) + + 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, + ) + + # 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, + ) + + # 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) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + @mock.patch("google.oauth2._client.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 4c75e371b..40a4ca219 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -203,6 +203,28 @@ def test_apply_with_no_quota_project_id(self): assert "x-goog-user-project" not in headers assert "token" in headers["authorization"] + @mock.patch("google.auth.jwt.Credentials.from_signing_credentials", autospec=True) + def test__create_self_signed_jwt(self, from_signing_credentials): + credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + from_signing_credentials.assert_called_once_with(credentials, audience) + + @mock.patch("google.auth.jwt.Credentials.from_signing_credentials", autospec=True) + def test__create_self_signed_jwt_with_user_scopes(self, from_signing_credentials): + credentials = service_account.Credentials( + SIGNER, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI, scopes=["foo"] + ) + + audience = "https://pubsub.googleapis.com" + credentials._create_self_signed_jwt(audience) + + # JWT should not be created if there are user-defined scopes + from_signing_credentials.assert_not_called() + @mock.patch("google.oauth2._client.jwt_grant", autospec=True) def test_refresh_success(self, jwt_grant): credentials = self.make_credentials() @@ -257,6 +279,32 @@ def test_before_request_refreshes(self, jwt_grant): # Credentials should now be valid. assert credentials.valid + @mock.patch("google.auth.jwt.Credentials._make_jwt") + def test_refresh_with_jwt_credentials(self, make_jwt): + credentials = self.make_credentials() + credentials._create_self_signed_jwt("https://pubsub.googleapis.com") + + request = mock.create_autospec(transport.Request, instance=True) + + token = "token" + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + make_jwt.return_value = (token, expiry) + + # Credentials should start as invalid + assert not credentials.valid + + # before_request should cause a refresh + credentials.before_request(request, "GET", "http://example.com?a=1#3", {}) + + # Credentials should now be valid. + assert credentials.valid + + # Assert make_jwt was called + assert make_jwt.called_once() + + assert credentials.token == token + assert credentials.expiry == expiry + class TestIDTokenCredentials(object): SERVICE_ACCOUNT_EMAIL = "service-account@example.com" diff --git a/tests/test__default.py b/tests/test__default.py index 261e35f6d..ef6cb78d2 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -247,18 +247,23 @@ def test_load_credentials_from_file_external_account_aws(get_project_id, tmpdir) @EXTERNAL_ACCOUNT_GET_PROJECT_ID_PATCH -def test_load_credentials_from_file_external_account_with_scopes( +def test_load_credentials_from_file_external_account_with_user_and_default_scopes( get_project_id, tmpdir ): config_file = tmpdir.join("config.json") config_file.write(json.dumps(IDENTITY_POOL_DATA)) credentials, project_id = _default.load_credentials_from_file( - str(config_file), scopes=["https://www.google.com/calendar/feeds"] + str(config_file), + scopes=["https://www.google.com/calendar/feeds"], + default_scopes=["https://www.googleapis.com/auth/cloud-platform"], ) assert isinstance(credentials, identity_pool.Credentials) assert project_id is mock.sentinel.project_id assert credentials.scopes == ["https://www.google.com/calendar/feeds"] + assert credentials.default_scopes == [ + "https://www.googleapis.com/auth/cloud-platform" + ] @EXTERNAL_ACCOUNT_GET_PROJECT_ID_PATCH @@ -316,7 +321,13 @@ def test__get_explicit_environ_credentials(load, monkeypatch): assert credentials is MOCK_CREDENTIALS assert project_id is mock.sentinel.project_id - load.assert_called_with("filename", request=None, scopes=None) + load.assert_called_with( + "filename", + scopes=None, + default_scopes=None, + quota_project_id=None, + request=None, + ) @LOAD_FILE_PATCH @@ -331,7 +342,13 @@ def test__get_explicit_environ_credentials_with_scopes_and_request(load, monkeyp assert credentials is MOCK_CREDENTIALS assert project_id is mock.sentinel.project_id # Request and scopes should be propagated. - load.assert_called_with("filename", request=mock.sentinel.request, scopes=scopes) + load.assert_called_with( + "filename", + scopes=scopes, + default_scopes=None, + quota_project_id=None, + request=mock.sentinel.request, + ) @LOAD_FILE_PATCH @@ -604,7 +621,7 @@ def test_default_scoped(with_scopes, unused_get): assert credentials == with_scopes.return_value assert project_id == mock.sentinel.project_id - with_scopes.assert_called_once_with(MOCK_CREDENTIALS, scopes) + with_scopes.assert_called_once_with(MOCK_CREDENTIALS, scopes, default_scopes=None) @mock.patch( @@ -651,7 +668,7 @@ def test_default_environ_external_credentials(get_project_id, monkeypatch, tmpdi @EXTERNAL_ACCOUNT_GET_PROJECT_ID_PATCH -def test_default_environ_external_credentials_with_scopes_and_quota_project_id( +def test_default_environ_external_credentials_with_user_and_default_scopes_and_quota_project_id( get_project_id, monkeypatch, tmpdir ): config_file = tmpdir.join("config.json") @@ -659,13 +676,18 @@ def test_default_environ_external_credentials_with_scopes_and_quota_project_id( monkeypatch.setenv(environment_vars.CREDENTIALS, str(config_file)) credentials, project_id = _default.default( - scopes=["https://www.google.com/calendar/feeds"], quota_project_id="project-foo" + scopes=["https://www.google.com/calendar/feeds"], + default_scopes=["https://www.googleapis.com/auth/cloud-platform"], + quota_project_id="project-foo", ) assert isinstance(credentials, identity_pool.Credentials) assert project_id is mock.sentinel.project_id assert credentials.quota_project_id == "project-foo" assert credentials.scopes == ["https://www.google.com/calendar/feeds"] + assert credentials.default_scopes == [ + "https://www.googleapis.com/auth/cloud-platform" + ] @EXTERNAL_ACCOUNT_GET_PROJECT_ID_PATCH diff --git a/tests/test_app_engine.py b/tests/test_app_engine.py index 846d31477..e335ff7ed 100644 --- a/tests/test_app_engine.py +++ b/tests/test_app_engine.py @@ -101,6 +101,7 @@ def test_default_state(self, app_identity): assert not credentials.expired # Scopes are required assert not credentials.scopes + assert not credentials.default_scopes assert credentials.requires_scopes assert not credentials.quota_project_id @@ -115,6 +116,20 @@ def test_with_scopes(self, app_identity): assert scoped_credentials.has_scopes(["email"]) assert not scoped_credentials.requires_scopes + def test_with_default_scopes(self, app_identity): + credentials = app_engine.Credentials() + + assert not credentials.scopes + assert not credentials.default_scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes( + scopes=None, default_scopes=["email"] + ) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.requires_scopes + def test_with_quota_project(self, app_identity): credentials = app_engine.Credentials() @@ -147,7 +162,9 @@ def test_refresh(self, utcnow, app_identity): token = "token" ttl = 643942923 app_identity.get_access_token.return_value = token, ttl - credentials = app_engine.Credentials(scopes=["email"]) + credentials = app_engine.Credentials( + scopes=["email"], default_scopes=["profile"] + ) credentials.refresh(None) @@ -159,6 +176,23 @@ def test_refresh(self, utcnow, app_identity): assert credentials.valid assert not credentials.expired + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_with_default_scopes(self, utcnow, app_identity): + token = "token" + ttl = 643942923 + app_identity.get_access_token.return_value = token, ttl + credentials = app_engine.Credentials(default_scopes=["email"]) + + credentials.refresh(None) + + app_identity.get_access_token.assert_called_with( + credentials.default_scopes, credentials._service_account_id + ) + assert credentials.token == token + assert credentials.expiry == datetime.datetime(1990, 5, 29, 1, 2, 3) + assert credentials.valid + assert not credentials.expired + def test_sign_bytes(self, app_identity): app_identity.sign_blob.return_value = ( mock.sentinel.key_id, diff --git a/tests/test_aws.py b/tests/test_aws.py index 07748f99f..9a8f98eec 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -718,6 +718,7 @@ def make_credentials( client_secret=None, quota_project_id=None, scopes=None, + default_scopes=None, service_account_impersonation_url=None, ): return aws.Credentials( @@ -730,6 +731,7 @@ def make_credentials( client_secret=client_secret, quota_project_id=quota_project_id, scopes=scopes, + default_scopes=default_scopes, ) @classmethod @@ -1143,7 +1145,7 @@ def test_retrieve_subject_token_error_determining_aws_security_creds(self): assert excinfo.match(r"Unable to retrieve AWS security credentials") @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_without_impersonation(self, utcnow): + def test_refresh_success_without_impersonation_ignore_default_scopes(self, utcnow): utcnow.return_value = datetime.datetime.strptime( self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" ) @@ -1182,6 +1184,8 @@ def test_refresh_success_without_impersonation(self, utcnow): credential_source=self.CREDENTIAL_SOURCE, quota_project_id=QUOTA_PROJECT_ID, scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], ) credentials.refresh(request) @@ -1194,9 +1198,66 @@ def test_refresh_success_without_impersonation(self, utcnow): assert credentials.token == self.SUCCESS_RESPONSE["access_token"] assert credentials.quota_project_id == QUOTA_PROJECT_ID assert credentials.scopes == SCOPES + assert credentials.default_scopes == ["ignored"] @mock.patch("google.auth._helpers.utcnow") - def test_refresh_success_with_impersonation(self, utcnow): + def test_refresh_success_without_impersonation_use_default_scopes(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expected_subject_token = self.make_serialized_aws_signed_request( + { + "access_key_id": ACCESS_KEY_ID, + "secret_access_key": SECRET_ACCESS_KEY, + "security_token": TOKEN, + } + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": " ".join(SCOPES), + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + token_status=http_client.OK, + token_data=self.SUCCESS_RESPONSE, + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 4 + # Fourth request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[3].kwargs, token_headers, token_request_data + ) + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes is None + assert credentials.default_scopes == SCOPES + + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_with_impersonation_ignore_default_scopes(self, utcnow): utcnow.return_value = datetime.datetime.strptime( self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" ) @@ -1256,6 +1317,8 @@ def test_refresh_success_with_impersonation(self, utcnow): service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, quota_project_id=QUOTA_PROJECT_ID, scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], ) credentials.refresh(request) @@ -1275,6 +1338,91 @@ def test_refresh_success_with_impersonation(self, utcnow): assert credentials.token == impersonation_response["accessToken"] assert credentials.quota_project_id == QUOTA_PROJECT_ID assert credentials.scopes == SCOPES + assert credentials.default_scopes == ["ignored"] + + @mock.patch("google.auth._helpers.utcnow") + def test_refresh_success_with_impersonation_use_default_scopes(self, utcnow): + utcnow.return_value = datetime.datetime.strptime( + self.AWS_SIGNATURE_TIME, "%Y-%m-%dT%H:%M:%SZ" + ) + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + expected_subject_token = self.make_serialized_aws_signed_request( + { + "access_key_id": ACCESS_KEY_ID, + "secret_access_key": SECRET_ACCESS_KEY, + "security_token": TOKEN, + } + ) + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "https://www.googleapis.com/auth/iam", + "subject_token": expected_subject_token, + "subject_token_type": SUBJECT_TOKEN_TYPE, + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(self.SUCCESS_RESPONSE["access_token"]), + "x-goog-user-project": QUOTA_PROJECT_ID, + } + impersonation_request_data = { + "delegates": None, + "scope": SCOPES, + "lifetime": "3600s", + } + request = self.make_mock_request( + region_status=http_client.OK, + region_name=self.AWS_REGION, + role_status=http_client.OK, + role_name=self.AWS_ROLE, + security_credentials_status=http_client.OK, + security_credentials_data=self.AWS_SECURITY_CREDENTIALS_RESPONSE, + token_status=http_client.OK, + token_data=self.SUCCESS_RESPONSE, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + quota_project_id=QUOTA_PROJECT_ID, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + credentials.refresh(request) + + assert len(request.call_args_list) == 5 + # Fourth request should be sent to GCP STS endpoint. + self.assert_token_request_kwargs( + request.call_args_list[3].kwargs, token_headers, token_request_data + ) + # Fifth request should be sent to iamcredentials endpoint for service + # account impersonation. + self.assert_impersonation_request_kwargs( + request.call_args_list[4].kwargs, + impersonation_headers, + impersonation_request_data, + ) + assert credentials.token == impersonation_response["accessToken"] + assert credentials.quota_project_id == QUOTA_PROJECT_ID + assert credentials.scopes is None + assert credentials.default_scopes == SCOPES def test_refresh_with_retrieve_subject_token_error(self): request = self.make_mock_request(region_status=http_client.BAD_REQUEST) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 0637b01e4..0633b38c0 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -142,16 +142,19 @@ def test_readonly_scoped_credentials_requires_scopes(): class RequiresScopedCredentialsImpl(credentials.Scoped, CredentialsImpl): - def __init__(self, scopes=None): + def __init__(self, scopes=None, default_scopes=None): super(RequiresScopedCredentialsImpl, self).__init__() self._scopes = scopes + self._default_scopes = default_scopes @property def requires_scopes(self): return not self.scopes - def with_scopes(self, scopes): - return RequiresScopedCredentialsImpl(scopes=scopes) + def with_scopes(self, scopes, default_scopes=None): + return RequiresScopedCredentialsImpl( + scopes=scopes, default_scopes=default_scopes + ) def test_create_scoped_if_required_scoped(): diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 5e38309f0..42e53ecb5 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -45,6 +45,7 @@ def __init__( client_secret=None, quota_project_id=None, scopes=None, + default_scopes=None, ): super(CredentialsImpl, self).__init__( audience=audience, @@ -56,6 +57,7 @@ def __init__( client_secret=client_secret, quota_project_id=quota_project_id, scopes=scopes, + default_scopes=default_scopes, ) self._counter = 0 @@ -122,6 +124,7 @@ def make_credentials( client_secret=None, quota_project_id=None, scopes=None, + default_scopes=None, service_account_impersonation_url=None, ): return CredentialsImpl( @@ -134,6 +137,7 @@ def make_credentials( client_secret=client_secret, quota_project_id=quota_project_id, scopes=scopes, + default_scopes=default_scopes, ) @classmethod @@ -231,19 +235,47 @@ def test_with_scopes(self): assert scoped_credentials.has_scopes(["email"]) assert not scoped_credentials.requires_scopes + def test_with_scopes_using_user_and_default_scopes(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes( + ["email"], default_scopes=["profile"] + ) + + assert scoped_credentials.has_scopes(["email"]) + assert not scoped_credentials.has_scopes(["profile"]) + assert not scoped_credentials.requires_scopes + assert scoped_credentials.scopes == ["email"] + assert scoped_credentials.default_scopes == ["profile"] + + def test_with_scopes_using_default_scopes_only(self): + credentials = self.make_credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes(None, default_scopes=["profile"]) + + assert scoped_credentials.has_scopes(["profile"]) + assert not scoped_credentials.requires_scopes + def test_with_scopes_full_options_propagated(self): credentials = self.make_credentials( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, quota_project_id=self.QUOTA_PROJECT_ID, scopes=self.SCOPES, + default_scopes=["default1"], service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, ) with mock.patch.object( external_account.Credentials, "__init__", return_value=None ) as mock_init: - credentials.with_scopes(["email"]) + credentials.with_scopes(["email"], ["default2"]) # Confirm with_scopes initialized the credential with the expected # parameters and scopes. @@ -257,6 +289,7 @@ def test_with_scopes_full_options_propagated(self): client_secret=CLIENT_SECRET, quota_project_id=self.QUOTA_PROJECT_ID, scopes=["email"], + default_scopes=["default2"], ) def test_with_quota_project(self): @@ -275,6 +308,7 @@ def test_with_quota_project_full_options_propagated(self): client_secret=CLIENT_SECRET, quota_project_id=self.QUOTA_PROJECT_ID, scopes=self.SCOPES, + default_scopes=["default1"], service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, ) @@ -295,6 +329,7 @@ def test_with_quota_project_full_options_propagated(self): client_secret=CLIENT_SECRET, quota_project_id="project-foo", scopes=self.SCOPES, + default_scopes=["default1"], ) def test_with_invalid_impersonation_target_principal(self): @@ -400,7 +435,9 @@ def test_refresh_impersonation_without_client_auth_success(self): assert not credentials.expired assert credentials.token == impersonation_response["accessToken"] - def test_refresh_without_client_auth_success_explicit_scopes(self): + def test_refresh_without_client_auth_success_explicit_user_scopes_ignore_default_scopes( + self + ): headers = {"Content-Type": "application/x-www-form-urlencoded"} request_data = { "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", @@ -413,7 +450,41 @@ def test_refresh_without_client_auth_success_explicit_scopes(self): request = self.make_mock_request( status=http_client.OK, data=self.SUCCESS_RESPONSE ) - credentials = self.make_credentials(scopes=["scope1", "scope2"]) + credentials = self.make_credentials( + scopes=["scope1", "scope2"], + # Default scopes will be ignored in favor of user scopes. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + self.assert_token_request_kwargs( + request.call_args.kwargs, headers, request_data + ) + assert credentials.valid + assert not credentials.expired + assert credentials.token == self.SUCCESS_RESPONSE["access_token"] + assert credentials.has_scopes(["scope1", "scope2"]) + assert not credentials.has_scopes(["ignored"]) + + def test_refresh_without_client_auth_success_explicit_default_scopes_only(self): + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "scope1 scope2", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + } + request = self.make_mock_request( + status=http_client.OK, data=self.SUCCESS_RESPONSE + ) + credentials = self.make_credentials( + scopes=None, + # Default scopes will be used since user scopes are none. + default_scopes=["scope1", "scope2"], + ) credentials.refresh(request) @@ -487,7 +558,7 @@ def test_refresh_with_client_auth_success(self): assert not credentials.expired assert credentials.token == self.SUCCESS_RESPONSE["access_token"] - def test_refresh_impersonation_with_client_auth_success(self): + def test_refresh_impersonation_with_client_auth_success_ignore_default_scopes(self): # Simulate service account access token expires in 2800 seconds. expire_time = ( _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) @@ -535,6 +606,79 @@ def test_refresh_impersonation_with_client_auth_success(self): client_secret=CLIENT_SECRET, service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, scopes=self.SCOPES, + # Default scopes will be ignored since user scopes are specified. + default_scopes=["ignored"], + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0].kwargs, token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1].kwargs, + impersonation_headers, + impersonation_request_data, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + + def test_refresh_impersonation_with_client_auth_success_use_default_scopes(self): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + } + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + } + impersonation_request_data = { + "delegates": None, + "scope": self.SCOPES, + "lifetime": "3600s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation and basic auth. + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=None, + # Default scopes will be used since user specified scopes are none. + default_scopes=self.SCOPES, ) credentials.refresh(request) diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index fc8ad8de9..c017ab59f 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -154,8 +154,10 @@ def assert_underlying_credentials_refresh( service_account_impersonation_url=None, basic_auth_encoding=None, quota_project_id=None, - scopes=None, + used_scopes=None, credential_data=None, + scopes=None, + default_scopes=None, ): """Utility to assert that a credentials are initialized with the expected attributes by calling refresh functionality and confirming response matches @@ -171,7 +173,7 @@ def assert_underlying_credentials_refresh( if service_account_impersonation_url: token_scopes = "https://www.googleapis.com/auth/iam" else: - token_scopes = " ".join(scopes or []) + token_scopes = " ".join(used_scopes or []) token_request_data = { "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", @@ -198,7 +200,7 @@ def assert_underlying_credentials_refresh( } impersonation_request_data = { "delegates": None, - "scope": scopes, + "scope": used_scopes, "lifetime": "3600s", } @@ -243,6 +245,7 @@ def assert_underlying_credentials_refresh( assert credentials.token == token_response["access_token"] assert credentials.quota_project_id == quota_project_id assert credentials.scopes == scopes + assert credentials.default_scopes == default_scopes @classmethod def make_credentials( @@ -251,6 +254,7 @@ def make_credentials( client_secret=None, quota_project_id=None, scopes=None, + default_scopes=None, service_account_impersonation_url=None, credential_source=None, ): @@ -264,6 +268,7 @@ def make_credentials( client_secret=client_secret, quota_project_id=quota_project_id, scopes=scopes, + default_scopes=default_scopes, ) @mock.patch.object(identity_pool.Credentials, "__init__", return_value=None) @@ -499,13 +504,17 @@ def test_retrieve_subject_token_file_not_found(self): assert excinfo.match(r"File './not_found.txt' was not found") - def test_refresh_text_file_success_without_impersonation(self): + def test_refresh_text_file_success_without_impersonation_ignore_default_scopes( + self + ): credentials = self.make_credentials( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, # Test with text format type. credential_source=self.CREDENTIAL_SOURCE_TEXT, scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], ) self.assert_underlying_credentials_refresh( @@ -517,16 +526,45 @@ def test_refresh_text_file_success_without_impersonation(self): service_account_impersonation_url=None, basic_auth_encoding=BASIC_AUTH_ENCODING, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=["ignored"], + ) + + def test_refresh_text_file_success_without_impersonation_use_default_scopes(self): + credentials = self.make_credentials( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + basic_auth_encoding=BASIC_AUTH_ENCODING, + quota_project_id=None, + used_scopes=SCOPES, + scopes=None, + default_scopes=SCOPES, ) - def test_refresh_text_file_success_with_impersonation(self): + def test_refresh_text_file_success_with_impersonation_ignore_default_scopes(self): # Initialize credentials with service account impersonation and basic auth. credentials = self.make_credentials( # Test with text format type. credential_source=self.CREDENTIAL_SOURCE_TEXT, service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, scopes=SCOPES, + # Default scopes should be ignored. + default_scopes=["ignored"], ) self.assert_underlying_credentials_refresh( @@ -538,7 +576,35 @@ def test_refresh_text_file_success_with_impersonation(self): service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, basic_auth_encoding=None, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=["ignored"], + ) + + def test_refresh_text_file_success_with_impersonation_use_default_scopes(self): + # Initialize credentials with service account impersonation, basic auth + # and default scopes (no user scopes). + credentials = self.make_credentials( + # Test with text format type. + credential_source=self.CREDENTIAL_SOURCE_TEXT, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + scopes=None, + # Default scopes should be used since user specified scopes are none. + default_scopes=SCOPES, + ) + + self.assert_underlying_credentials_refresh( + credentials=credentials, + audience=AUDIENCE, + subject_token=TEXT_FILE_SUBJECT_TOKEN, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=SCOPES, + scopes=None, + default_scopes=SCOPES, ) def test_refresh_json_file_success_without_impersonation(self): @@ -559,7 +625,9 @@ def test_refresh_json_file_success_without_impersonation(self): service_account_impersonation_url=None, basic_auth_encoding=BASIC_AUTH_ENCODING, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=None, ) def test_refresh_json_file_success_with_impersonation(self): @@ -580,7 +648,9 @@ def test_refresh_json_file_success_with_impersonation(self): service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, basic_auth_encoding=None, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=None, ) def test_refresh_with_retrieve_subject_token_error(self): @@ -708,7 +778,9 @@ def test_refresh_text_file_success_without_impersonation_url(self): service_account_impersonation_url=None, basic_auth_encoding=BASIC_AUTH_ENCODING, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=None, credential_data=TEXT_FILE_SUBJECT_TOKEN, ) @@ -730,7 +802,9 @@ def test_refresh_text_file_success_with_impersonation_url(self): service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, basic_auth_encoding=None, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=None, credential_data=TEXT_FILE_SUBJECT_TOKEN, ) @@ -752,7 +826,9 @@ def test_refresh_json_file_success_without_impersonation_url(self): service_account_impersonation_url=None, basic_auth_encoding=BASIC_AUTH_ENCODING, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=None, credential_data=JSON_FILE_CONTENT, ) @@ -774,7 +850,9 @@ def test_refresh_json_file_success_with_impersonation_url(self): service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, basic_auth_encoding=None, quota_project_id=None, + used_scopes=SCOPES, scopes=SCOPES, + default_scopes=None, credential_data=JSON_FILE_CONTENT, ) diff --git a/tests/transport/test_grpc.py b/tests/transport/test_grpc.py index 39f8b11c8..1602f4c0f 100644 --- a/tests/transport/test_grpc.py +++ b/tests/transport/test_grpc.py @@ -24,6 +24,7 @@ from google.auth import environment_vars from google.auth import exceptions from google.auth import transport +from google.oauth2 import service_account try: # pylint: disable=ungrouped-imports @@ -74,7 +75,7 @@ def test_call_no_refresh(self): time.sleep(2) callback.assert_called_once_with( - [(u"authorization", u"Bearer {}".format(credentials.token))], None + [("authorization", "Bearer {}".format(credentials.token))], None ) def test_call_refresh(self): @@ -95,7 +96,41 @@ def test_call_refresh(self): assert credentials.token == "token1" callback.assert_called_once_with( - [(u"authorization", u"Bearer {}".format(credentials.token))], None + [("authorization", "Bearer {}".format(credentials.token))], None + ) + + def test__get_authorization_headers_with_service_account(self): + credentials = mock.create_autospec(service_account.Credentials) + request = mock.create_autospec(transport.Request) + + plugin = google.auth.transport.grpc.AuthMetadataPlugin(credentials, request) + + context = mock.create_autospec(grpc.AuthMetadataContext, instance=True) + context.method_name = "methodName" + context.service_url = "https://pubsub.googleapis.com/methodName" + + plugin._get_authorization_headers(context) + + # self-signed JWT should not be created when default_host is not set + credentials._create_self_signed_jwt.assert_not_called() + + def test__get_authorization_headers_with_service_account_and_default_host(self): + credentials = mock.create_autospec(service_account.Credentials) + request = mock.create_autospec(transport.Request) + + default_host = "pubsub.googleapis.com" + plugin = google.auth.transport.grpc.AuthMetadataPlugin( + credentials, request, default_host=default_host + ) + + context = mock.create_autospec(grpc.AuthMetadataContext, instance=True) + context.method_name = "methodName" + context.service_url = "https://pubsub.googleapis.com/methodName" + + plugin._get_authorization_headers(context) + + credentials._create_self_signed_jwt.assert_called_once_with( + "https://{}/".format(default_host) ) diff --git a/tests/transport/test_requests.py b/tests/transport/test_requests.py index d56c2be55..3fdd17c3e 100644 --- a/tests/transport/test_requests.py +++ b/tests/transport/test_requests.py @@ -30,6 +30,7 @@ import google.auth.credentials import google.auth.transport._mtls_helper import google.auth.transport.requests +from google.oauth2 import service_account from tests.transport import compliance @@ -372,6 +373,25 @@ def test_request_timeout_w_refresh_timeout_timeout_error(self, frozen_time): "GET", self.TEST_URL, timeout=60, max_allowed_time=2.9 ) + def test_authorized_session_without_default_host(self): + credentials = mock.create_autospec(service_account.Credentials) + + authed_session = google.auth.transport.requests.AuthorizedSession(credentials) + + authed_session.credentials._create_self_signed_jwt.assert_not_called() + + def test_authorized_session_with_default_host(self): + default_host = "pubsub.googleapis.com" + credentials = mock.create_autospec(service_account.Credentials) + + authed_session = google.auth.transport.requests.AuthorizedSession( + credentials, default_host=default_host + ) + + authed_session.credentials._create_self_signed_jwt.assert_called_once_with( + "https://{}/".format(default_host) + ) + def test_configure_mtls_channel_with_callback(self): mock_callback = mock.Mock() mock_callback.return_value = ( diff --git a/tests/transport/test_urllib3.py b/tests/transport/test_urllib3.py index 29561f6d6..7c0693476 100644 --- a/tests/transport/test_urllib3.py +++ b/tests/transport/test_urllib3.py @@ -26,6 +26,7 @@ import google.auth.credentials import google.auth.transport._mtls_helper import google.auth.transport.urllib3 +from google.oauth2 import service_account from tests.transport import compliance @@ -158,6 +159,25 @@ def test_urlopen_refresh(self): ("GET", self.TEST_URL, None, {"authorization": "token1"}, {}), ] + def test_urlopen_no_default_host(self): + credentials = mock.create_autospec(service_account.Credentials) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp(credentials) + + authed_http.credentials._create_self_signed_jwt.assert_not_called() + + def test_urlopen_with_default_host(self): + default_host = "pubsub.googleapis.com" + credentials = mock.create_autospec(service_account.Credentials) + + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials, default_host=default_host + ) + + authed_http.credentials._create_self_signed_jwt.assert_called_once_with( + "https://{}/".format(default_host) + ) + def test_proxies(self): http = mock.create_autospec(urllib3.PoolManager) authed_http = google.auth.transport.urllib3.AuthorizedHttp(None, http=http)