From bf5ce0c56c10f655ced6630653f0f2ad47fcceeb Mon Sep 17 00:00:00 2001 From: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> Date: Mon, 1 Feb 2021 15:17:49 -0700 Subject: [PATCH] feat: use self-signed jwt for service account (#665) --- google/auth/_default.py | 16 ++++-- google/auth/app_engine.py | 21 +++++--- google/auth/compute_engine/credentials.py | 18 +++++-- google/auth/credentials.py | 19 +++++-- google/auth/transport/grpc.py | 20 ++++++- google/oauth2/credentials.py | 14 +++-- google/oauth2/service_account.py | 44 +++++++++++++--- system_tests/system_tests_sync/test_grpc.py | 29 ++++++++++- tests/compute_engine/test__metadata.py | 38 ++++++++++++++ tests/oauth2/test_credentials.py | 58 +++++++++++++++++++++ tests/oauth2/test_service_account.py | 48 +++++++++++++++++ tests/test__default.py | 2 +- tests/test_app_engine.py | 36 ++++++++++++- tests/test_credentials.py | 9 ++-- tests/transport/test_grpc.py | 39 +++++++++++++- 15 files changed, 373 insertions(+), 38 deletions(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index 43778931a..3b8c281e7 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -69,7 +69,9 @@ def _warn_about_problematic_credentials(credentials): warnings.warn(_CLOUD_SDK_CREDENTIALS_WARNING) -def load_credentials_from_file(filename, scopes=None, quota_project_id=None): +def load_credentials_from_file( + filename, scopes=None, default_scopes=None, quota_project_id=None +): """Loads Google credentials from a file. The credentials file must be a service account key or stored authorized @@ -80,6 +82,8 @@ def load_credentials_from_file(filename, scopes=None, quota_project_id=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. quota_project_id (Optional[str]): The project ID used for quota and billing. @@ -132,7 +136,7 @@ def load_credentials_from_file(filename, scopes=None, quota_project_id=None): 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) @@ -248,7 +252,7 @@ def _get_gce_credentials(request=None): return None, None -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 @@ -312,6 +316,8 @@ def default(scopes=None, request=None, quota_project_id=None): use the standard library http client to make requests. 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 @@ -339,7 +345,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/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/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/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/system_tests/system_tests_sync/test_grpc.py b/system_tests/system_tests_sync/test_grpc.py index 7dcbd4c43..da2eb71fb 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,9 @@ 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 + assert credentials._jwt_credentials is not None def test_grpc_request_with_jwt_credentials(): 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 2738e22bc..74511f9e5 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -471,7 +471,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( 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_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/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) )