diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4393ea1..ed2b168d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ [1]: https://pypi.org/project/google-auth/#history +## [1.13.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.12.0...v1.13.0) (2020-04-01) + + +### Features + +* add access token credentials ([#476](https://www.github.com/googleapis/google-auth-library-python/issues/476)) ([772dac6](https://www.github.com/googleapis/google-auth-library-python/commit/772dac6a6512230d32cb0dfae65a1a6aa9015049)) +* add fetch_id_token to support id_token adc ([#469](https://www.github.com/googleapis/google-auth-library-python/issues/469)) ([506c565](https://www.github.com/googleapis/google-auth-library-python/commit/506c565a8c3c23a78fd0f17991bc6deb6f2528a9)) +* consolidate mTLS channel errors ([#480](https://www.github.com/googleapis/google-auth-library-python/issues/480)) ([e83d446](https://www.github.com/googleapis/google-auth-library-python/commit/e83d4462f5c50f8424d9e54be32c29390115a9ed)) +* Implement ES256 for JWT verification ([#340](https://www.github.com/googleapis/google-auth-library-python/issues/340)) ([e290a3d](https://www.github.com/googleapis/google-auth-library-python/commit/e290a3dbecc4767dd25ee14574951cdb6c2157cb)) + ## [1.12.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.11.3...v1.12.0) (2020-03-25) diff --git a/docs/reference/google.auth.crypt.es256.rst b/docs/reference/google.auth.crypt.es256.rst new file mode 100644 index 000000000..5a6318482 --- /dev/null +++ b/docs/reference/google.auth.crypt.es256.rst @@ -0,0 +1,7 @@ +google.auth.crypt.es256 module +============================== + +.. automodule:: google.auth.crypt.es256 + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.crypt.rst b/docs/reference/google.auth.crypt.rst index 0833e7f2f..be142f428 100644 --- a/docs/reference/google.auth.crypt.rst +++ b/docs/reference/google.auth.crypt.rst @@ -12,4 +12,5 @@ Submodules .. toctree:: google.auth.crypt.base + google.auth.crypt.es256 google.auth.crypt.rsa diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 8dabaf9d6..89ad689a7 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,3 +1,4 @@ +cryptography sphinx-docstring-typing urllib3 requests diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 0abe160a3..08e7167df 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -291,7 +291,35 @@ Impersonated :: target_credentials, target_audience=target_audience) -IDToken verification can be done for various type of IDTokens using the :class:`google.oauth2.id_token` module +If your application runs on `App Engine`_, `Cloud Run`_, `Compute Engine`_, or +has application default credentials set via `GOOGLE_APPLICATION_CREDENTIALS` +environment variable, you can also use `google.oauth2.id_token.fetch_id_token` +to obtain an ID token from your current running environment. The following is an +example :: + + import google.oauth2.id_token + import google.auth.transport.requests + + request = google.auth.transport.requests.Request() + target_audience = "https://pubsub.googleapis.com" + + id_token = google.oauth2.id_token.fetch_id_token(request, target_audience) + +IDToken verification can be done for various type of IDTokens using the +:class:`google.oauth2.id_token` module. It supports ID token signed with RS256 +and ES256 algorithms. However, ES256 algorithm won't be available unless +`cryptography` dependency of version at least 1.4.0 is installed. You can check +the dependency with `pip freeze` or try `from google.auth.crypt import es256`. +The following is an example of verifying ID tokens: + + from google.auth2 import id_token + + request = google.auth.transport.requests.Request() + + try: + decoded_token = id_token.verify_token(token_to_verify,request) + except ValueError: + # Verification failed. A sample end-to-end flow using an ID Token against a Cloud Run endpoint maybe :: @@ -320,8 +348,10 @@ A sample end-to-end flow using an ID Token against a Cloud Run endpoint maybe :: print(token) print(id_token.verify_token(token,request)) +.. _App Engine: https://cloud.google.com/appengine/ .. _Cloud Functions: https://cloud.google.com/functions/ .. _Cloud Run: https://cloud.google.com/run/ +.. _Compute Engine: https://cloud.google.com/compute/ .. _Identity Aware Proxy: https://cloud.google.com/iap/ .. _Google OpenID Connect: https://developers.google.com/identity/protocols/OpenIDConnect .. _Google ID Token: https://developers.google.com/identity/protocols/OpenIDConnect#validatinganidtoken diff --git a/google/auth/_cloud_sdk.py b/google/auth/_cloud_sdk.py index 61ffd4f5c..e772fe964 100644 --- a/google/auth/_cloud_sdk.py +++ b/google/auth/_cloud_sdk.py @@ -18,8 +18,10 @@ import os import subprocess +import six + from google.auth import environment_vars -import google.oauth2.credentials +from google.auth import exceptions # The ~/.config subdirectory containing gcloud credentials. @@ -34,6 +36,8 @@ _CLOUD_SDK_WINDOWS_COMMAND = "gcloud.cmd" # The command to get the Cloud SDK configuration _CLOUD_SDK_CONFIG_COMMAND = ("config", "config-helper", "--format", "json") +# The command to get google user access token +_CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND = ("auth", "print-access-token") # Cloud SDK's application-default client ID CLOUD_SDK_CLIENT_ID = ( "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com" @@ -80,21 +84,6 @@ def get_application_default_credentials_path(): return os.path.join(config_path, _CREDENTIALS_FILENAME) -def load_authorized_user_credentials(info): - """Loads an authorized user credential. - - Args: - info (Mapping[str, str]): The loaded file's data. - - Returns: - google.oauth2.credentials.Credentials: The constructed credentials. - - Raises: - ValueError: if the info is in the wrong format or missing data. - """ - return google.oauth2.credentials.Credentials.from_authorized_user_info(info) - - def get_project_id(): """Gets the project ID from the Cloud SDK. @@ -122,3 +111,42 @@ def get_project_id(): return configuration["configuration"]["properties"]["core"]["project"] except KeyError: return None + + +def get_auth_access_token(account=None): + """Load user access token with the ``gcloud auth print-access-token`` command. + + Args: + account (Optional[str]): Account to get the access token for. If not + specified, the current active account will be used. + + Returns: + str: The user access token. + + Raises: + google.auth.exceptions.UserAccessTokenError: if failed to get access + token from gcloud. + """ + if os.name == "nt": + command = _CLOUD_SDK_WINDOWS_COMMAND + else: + command = _CLOUD_SDK_POSIX_COMMAND + + try: + if account: + command = ( + (command,) + + _CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND + + ("--account=" + account,) + ) + else: + command = (command,) + _CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND + + access_token = subprocess.check_output(command, stderr=subprocess.STDOUT) + # remove the trailing "\n" + return access_token.decode("utf-8").strip() + except (subprocess.CalledProcessError, OSError, IOError) as caught_exc: + new_exc = exceptions.UserAccessTokenError( + "Failed to obtain access token", caught_exc + ) + six.raise_from(new_exc, caught_exc) diff --git a/google/auth/_default.py b/google/auth/_default.py index 32e81ba5f..d7110a10d 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -106,10 +106,10 @@ def _load_credentials_from_file(filename): credential_type = info.get("type") if credential_type == _AUTHORIZED_USER_TYPE: - from google.auth import _cloud_sdk + from google.oauth2 import credentials try: - credentials = _cloud_sdk.load_authorized_user_credentials(info) + credentials = credentials.Credentials.from_authorized_user_info(info) except ValueError as caught_exc: msg = "Failed to load authorized user credentials from {}".format(filename) new_exc = exceptions.DefaultCredentialsError(msg, caught_exc) diff --git a/google/auth/crypt/__init__.py b/google/auth/crypt/__init__.py index 39929fa0a..15ac95068 100644 --- a/google/auth/crypt/__init__.py +++ b/google/auth/crypt/__init__.py @@ -31,6 +31,10 @@ private_key = open('private_key.pem').read() signer = crypt.RSASigner.from_string(private_key) signature = signer.sign(message) + +The code above also works for :class:`ES256Signer` and :class:`ES256Verifier`. +Note that these two classes are only available if your `cryptography` dependency +version is at least 1.4.0. """ import six @@ -38,8 +42,23 @@ from google.auth.crypt import base from google.auth.crypt import rsa +try: + from google.auth.crypt import es256 +except ImportError: # pragma: NO COVER + es256 = None + +if es256 is not None: # pragma: NO COVER + __all__ = [ + "ES256Signer", + "ES256Verifier", + "RSASigner", + "RSAVerifier", + "Signer", + "Verifier", + ] +else: # pragma: NO COVER + __all__ = ["RSASigner", "RSAVerifier", "Signer", "Verifier"] -__all__ = ["RSASigner", "RSAVerifier", "Signer", "Verifier"] # Aliases to maintain the v1.0.0 interface, as the crypt module was split # into submodules. @@ -48,9 +67,13 @@ RSASigner = rsa.RSASigner RSAVerifier = rsa.RSAVerifier +if es256 is not None: # pragma: NO COVER + ES256Signer = es256.ES256Signer + ES256Verifier = es256.ES256Verifier + -def verify_signature(message, signature, certs): - """Verify an RSA cryptographic signature. +def verify_signature(message, signature, certs, verifier_cls=rsa.RSAVerifier): + """Verify an RSA or ECDSA cryptographic signature. Checks that the provided ``signature`` was generated from ``bytes`` using the private key associated with the ``cert``. @@ -60,6 +83,9 @@ def verify_signature(message, signature, certs): signature (Union[str, bytes]): The cryptographic signature to check. certs (Union[Sequence, str, bytes]): The certificate or certificates to use to check the signature. + verifier_cls (Optional[~google.auth.crypt.base.Signer]): Which verifier + class to use for verification. This can be used to select different + algorithms, such as RSA or ECDSA. Default value is :class:`RSAVerifier`. Returns: bool: True if the signature is valid, otherwise False. @@ -68,7 +94,7 @@ def verify_signature(message, signature, certs): certs = [certs] for cert in certs: - verifier = rsa.RSAVerifier.from_string(cert) + verifier = verifier_cls.from_string(cert) if verifier.verify(message, signature): return True return False diff --git a/google/auth/crypt/es256.py b/google/auth/crypt/es256.py new file mode 100644 index 000000000..5bfd57fb8 --- /dev/null +++ b/google/auth/crypt/es256.py @@ -0,0 +1,145 @@ +# Copyright 2017 Google Inc. +# +# 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. + +"""ECDSA (ES256) verifier and signer that use the ``cryptography`` library. +""" + +import cryptography.exceptions +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import padding +import cryptography.x509 +import pkg_resources + +from google.auth import _helpers +from google.auth.crypt import base + +_IMPORT_ERROR_MSG = ( + "cryptography>=1.4.0 is required to use cryptography-based ECDSA " "algorithms" +) + +try: # pragma: NO COVER + release = pkg_resources.get_distribution("cryptography").parsed_version + if release < pkg_resources.parse_version("1.4.0"): + raise ImportError(_IMPORT_ERROR_MSG) +except pkg_resources.DistributionNotFound: # pragma: NO COVER + raise ImportError(_IMPORT_ERROR_MSG) + + +_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" +_BACKEND = backends.default_backend() +_PADDING = padding.PKCS1v15() + + +class ES256Verifier(base.Verifier): + """Verifies ECDSA cryptographic signatures using public keys. + + Args: + public_key ( + cryptography.hazmat.primitives.asymmetric.ec.ECDSAPublicKey): + The public key used to verify signatures. + """ + + def __init__(self, public_key): + self._pubkey = public_key + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message, signature): + message = _helpers.to_bytes(message) + try: + self._pubkey.verify(signature, message, ec.ECDSA(hashes.SHA256())) + return True + except (ValueError, cryptography.exceptions.InvalidSignature): + return False + + @classmethod + def from_string(cls, public_key): + """Construct an Verifier instance from a public key or public + certificate string. + + Args: + public_key (Union[str, bytes]): The public key in PEM format or the + x509 public key certificate. + + Returns: + Verifier: The constructed verifier. + + Raises: + ValueError: If the public key can't be parsed. + """ + public_key_data = _helpers.to_bytes(public_key) + + if _CERTIFICATE_MARKER in public_key_data: + cert = cryptography.x509.load_pem_x509_certificate( + public_key_data, _BACKEND + ) + pubkey = cert.public_key() + + else: + pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND) + + return cls(pubkey) + + +class ES256Signer(base.Signer, base.FromServiceAccountMixin): + """Signs messages with an ECDSA private key. + + Args: + private_key ( + cryptography.hazmat.primitives.asymmetric.ec.ECDSAPrivateKey): + The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + """ + + def __init__(self, private_key, key_id=None): + self._key = private_key + self._key_id = key_id + + @property + @_helpers.copy_docstring(base.Signer) + def key_id(self): + return self._key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message): + message = _helpers.to_bytes(message) + return self._key.sign(message, ec.ECDSA(hashes.SHA256())) + + @classmethod + def from_string(cls, key, key_id=None): + """Construct a RSASigner from a private key in PEM format. + + Args: + key (Union[bytes, str]): Private key in PEM format. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt._cryptography_rsa.RSASigner: The + constructed signer. + + Raises: + ValueError: If ``key`` is not ``bytes`` or ``str`` (unicode). + UnicodeDecodeError: If ``key`` is ``bytes`` but cannot be decoded + into a UTF-8 ``str``. + ValueError: If ``cryptography`` "Could not deserialize key data." + """ + key = _helpers.to_bytes(key) + private_key = serialization.load_pem_private_key( + key, password=None, backend=_BACKEND + ) + return cls(private_key, key_id=key_id) diff --git a/google/auth/exceptions.py b/google/auth/exceptions.py index e034c55cd..95013860a 100644 --- a/google/auth/exceptions.py +++ b/google/auth/exceptions.py @@ -28,5 +28,13 @@ class RefreshError(GoogleAuthError): failed.""" +class UserAccessTokenError(GoogleAuthError): + """Used to indicate ``gcloud auth print-access-token`` command failed.""" + + class DefaultCredentialsError(GoogleAuthError): """Used to indicate that acquiring default credentials failed.""" + + +class MutualTLSChannelError(GoogleAuthError): + """Used to indicate that mutual TLS channel creation is failed.""" diff --git a/google/auth/jwt.py b/google/auth/jwt.py index cdd69ac8a..9248eb27f 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -59,8 +59,18 @@ from google.auth import exceptions import google.auth.credentials +try: + from google.auth.crypt import es256 +except ImportError: # pragma: NO COVER + es256 = None + _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds _DEFAULT_MAX_CACHE_SIZE = 10 +_ALGORITHM_TO_VERIFIER_CLASS = {"RS256": crypt.RSAVerifier} +_CRYPTOGRAPHY_BASED_ALGORITHMS = set(["ES256"]) + +if es256 is not None: # pragma: NO COVER + _ALGORITHM_TO_VERIFIER_CLASS["ES256"] = es256.ES256Verifier def encode(signer, payload, header=None, key_id=None): @@ -83,7 +93,12 @@ def encode(signer, payload, header=None, key_id=None): if key_id is None: key_id = signer.key_id - header.update({"typ": "JWT", "alg": "RS256"}) + header.update({"typ": "JWT"}) + + if es256 is not None and isinstance(signer, es256.ES256Signer): + header.update({"alg": "ES256"}) + else: + header.update({"alg": "RS256"}) if key_id is not None: header["kid"] = key_id @@ -217,10 +232,30 @@ def decode(token, certs=None, verify=True, audience=None): if not verify: return payload + # Pluck the key id and algorithm from the header and make sure we have + # a verifier that can support it. + key_alg = header.get("alg") + key_id = header.get("kid") + + try: + verifier_cls = _ALGORITHM_TO_VERIFIER_CLASS[key_alg] + except KeyError as exc: + if key_alg in _CRYPTOGRAPHY_BASED_ALGORITHMS: + six.raise_from( + ValueError( + "The key algorithm {} requires the cryptography package " + "to be installed.".format(key_alg) + ), + exc, + ) + else: + six.raise_from( + ValueError("Unsupported signature algorithm {}".format(key_alg)), exc + ) + # If certs is specified as a dictionary of key IDs to certificates, then # use the certificate identified by the key ID in the token header. if isinstance(certs, Mapping): - key_id = header.get("kid") if key_id: if key_id not in certs: raise ValueError("Certificate for key id {} not found.".format(key_id)) @@ -232,7 +267,9 @@ def decode(token, certs=None, verify=True, audience=None): certs_to_check = certs # Verify that the signature matches the message. - if not crypt.verify_signature(signed_section, signature, certs_to_check): + if not crypt.verify_signature( + signed_section, signature, certs_to_check, verifier_cls + ): raise ValueError("Could not verify token signature.") # Verify the issued at and created times in the payload. diff --git a/google/auth/transport/grpc.py b/google/auth/transport/grpc.py index 32ffabcae..d62c41502 100644 --- a/google/auth/transport/grpc.py +++ b/google/auth/transport/grpc.py @@ -20,6 +20,7 @@ import six +from google.auth import exceptions from google.auth.transport import _mtls_helper try: @@ -217,17 +218,8 @@ def my_client_cert_callback(): grpc.Channel: The created gRPC channel. Raises: - OSError: If the cert provider command launch fails during the application - default SSL credentials loading process on devices with endpoint - verification support. - RuntimeError: If the cert provider command has a runtime error during the - application default SSL credentials loading process on devices with - endpoint verification support. - ValueError: - If the context aware metadata file is malformed or if the cert provider - command doesn't produce both client certificate and key during the - application default SSL credentials loading process on devices with - endpoint verification support. + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. """ # Create the metadata plugin for inserting the authorization header. metadata_plugin = AuthMetadataPlugin(credentials, request) @@ -293,20 +285,21 @@ def ssl_credentials(self): grpc.ChannelCredentials: The created grpc channel credentials. Raises: - OSError: If the cert provider command launch fails. - RuntimeError: If the cert provider command has a runtime error. - ValueError: - If the context aware metadata file is malformed or if the cert provider - command doesn't produce both the client certificate and key. + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. """ if self._context_aware_metadata_path: - metadata = _mtls_helper._read_dca_metadata_file( - self._context_aware_metadata_path - ) - cert, key = _mtls_helper.get_client_ssl_credentials(metadata) - self._ssl_credentials = grpc.ssl_channel_credentials( - certificate_chain=cert, private_key=key - ) + try: + metadata = _mtls_helper._read_dca_metadata_file( + self._context_aware_metadata_path + ) + cert, key = _mtls_helper.get_client_ssl_credentials(metadata) + self._ssl_credentials = grpc.ssl_channel_credentials( + certificate_chain=cert, private_key=key + ) + except (OSError, RuntimeError, ValueError) as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + six.raise_from(new_exc, caught_exc) else: self._ssl_credentials = grpc.ssl_channel_credentials() diff --git a/google/auth/transport/requests.py b/google/auth/transport/requests.py index 2d31d962e..26096e213 100644 --- a/google/auth/transport/requests.py +++ b/google/auth/transport/requests.py @@ -355,23 +355,32 @@ def configure_mtls_channel(self, client_cert_callback=None): will be used. Raises: - ImportError: If certifi or pyOpenSSL is not installed. - OpenSSL.crypto.Error: If client cert or key is invalid. - OSError: If the cert provider command launch fails during the - application default SSL credentials loading process. - RuntimeError: If the cert provider command has a runtime error during - the application default SSL credentials loading process. - ValueError: If the context aware metadata file is malformed or the - cert provider command doesn't produce both client certicate and - key during the application default SSL credentials loading process. + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. """ - self._is_mtls, cert, key = google.auth.transport._mtls_helper.get_client_cert_and_key( - client_cert_callback - ) + try: + import OpenSSL + except ImportError as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + six.raise_from(new_exc, caught_exc) - if self._is_mtls: - mtls_adapter = _MutualTlsAdapter(cert, key) - self.mount("https://", mtls_adapter) + try: + self._is_mtls, cert, key = google.auth.transport._mtls_helper.get_client_cert_and_key( + client_cert_callback + ) + + if self._is_mtls: + mtls_adapter = _MutualTlsAdapter(cert, key) + self.mount("https://", mtls_adapter) + except ( + ImportError, + OpenSSL.crypto.Error, + OSError, + RuntimeError, + ValueError, + ) as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + six.raise_from(new_exc, caught_exc) def request( self, diff --git a/google/auth/transport/urllib3.py b/google/auth/transport/urllib3.py index 3b2ba28bc..c359f3592 100644 --- a/google/auth/transport/urllib3.py +++ b/google/auth/transport/urllib3.py @@ -297,24 +297,33 @@ def configure_mtls_channel(self, client_cert_callabck=None): True if the channel is mutual TLS and False otherwise. Raises: - ImportError: If certifi or pyOpenSSL is not installed. - OpenSSL.crypto.Error: If client cert or key is invalid. - OSError: If the cert provider command launch fails during the - application default SSL credentials loading process. - RuntimeError: If the cert provider command has a runtime error during - the application default SSL credentials loading process. - ValueError: If the context aware metadata file is malformed or the - cert provider command doesn't produce both client certicate and - key during the application default SSL credentials loading process. + google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel + creation failed for any reason. """ - found_cert_key, cert, key = transport._mtls_helper.get_client_cert_and_key( - client_cert_callabck - ) + try: + import OpenSSL + except ImportError as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + six.raise_from(new_exc, caught_exc) - if found_cert_key: - self.http = _make_mutual_tls_http(cert, key) - else: - self.http = _make_default_http() + try: + found_cert_key, cert, key = transport._mtls_helper.get_client_cert_and_key( + client_cert_callabck + ) + + if found_cert_key: + self.http = _make_mutual_tls_http(cert, key) + else: + self.http = _make_default_http() + except ( + ImportError, + OpenSSL.crypto.Error, + OSError, + RuntimeError, + ValueError, + ) as caught_exc: + new_exc = exceptions.MutualTLSChannelError(caught_exc) + six.raise_from(new_exc, caught_exc) if self._has_user_provided_http: self._has_user_provided_http = False diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 1adcbf675..baf3cf7f4 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -36,6 +36,7 @@ import six +from google.auth import _cloud_sdk from google.auth import _helpers from google.auth import credentials from google.auth import exceptions @@ -292,3 +293,50 @@ def to_json(self, strip=None): prep = {k: v for k, v in prep.items() if k not in strip} return json.dumps(prep) + + +class UserAccessTokenCredentials(credentials.Credentials): + """Access token credentials for user account. + + Obtain the access token for a given user account or the current active + user account with the ``gcloud auth print-access-token`` command. + + Args: + account (Optional[str]): Account to get the access token for. If not + specified, the current active account will be used. + """ + + def __init__(self, account=None): + super(UserAccessTokenCredentials, self).__init__() + self._account = account + + def with_account(self, account): + """Create a new instance with the given account. + + Args: + account (str): Account to get the access token for. + + Returns: + google.oauth2.credentials.UserAccessTokenCredentials: The created + credentials with the given account. + """ + return self.__class__(account=account) + + def refresh(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): This argument is required + by the base class interface but not used in this implementation, + so just set it to `None`. + + Raises: + google.auth.exceptions.UserAccessTokenError: If the access token + refresh failed. + """ + self.token = _cloud_sdk.get_auth_access_token(self._account) + + @_helpers.copy_docstring(credentials.Credentials) + def before_request(self, request, method, url, headers): + self.refresh(request) + self.apply(headers) diff --git a/google/oauth2/id_token.py b/google/oauth2/id_token.py index 1dbfb20ad..f559c6c39 100644 --- a/google/oauth2/id_token.py +++ b/google/oauth2/id_token.py @@ -59,12 +59,16 @@ """ import json +import os +import six from six.moves import http_client +from google.auth import environment_vars from google.auth import exceptions from google.auth import jwt + # The URL that provides public certificates for verifying ID tokens issued # by Google's OAuth 2.0 authorization server. _GOOGLE_OAUTH2_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs" @@ -159,3 +163,90 @@ def verify_firebase_token(id_token, request, audience=None): return verify_token( id_token, request, audience=audience, certs_url=_GOOGLE_APIS_CERTS_URL ) + + +def fetch_id_token(request, audience): + """Fetch the ID Token from the current environment. + + This function acquires ID token from the environment in the following order: + + 1. If the application is running in Compute Engine, App Engine or Cloud Run, + then the ID token are obtained from the metadata server. + 2. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set + to the path of a valid service account JSON file, then ID token is + acquired using this service account credentials. + 3. If metadata server doesn't exist and no valid service account credentials + are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will + be raised. + + Example:: + + import google.oauth2.id_token + import google.auth.transport.requests + + request = google.auth.transport.requests.Request() + target_audience = "https://pubsub.googleapis.com" + + id_token = google.oauth2.id_token.fetch_id_token(request, target_audience) + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + audience (str): The audience that this ID token is intended for. + + Returns: + str: The ID token. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If metadata server doesn't exist and no valid service account + credentials are found. + """ + # 1. First try to fetch ID token from metada server if it exists. The code + # works for GAE and Cloud Run metadata server as well. + try: + from google.auth import compute_engine + + credentials = compute_engine.IDTokenCredentials( + request, audience, use_metadata_identity_endpoint=True + ) + credentials.refresh(request) + return credentials.token + except (ImportError, exceptions.TransportError): + pass + + # 2. Try to use service account credentials to get ID token. + + # Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + # variable. + credentials_filename = os.environ.get(environment_vars.CREDENTIALS) + if not ( + credentials_filename + and os.path.exists(credentials_filename) + and os.path.isfile(credentials_filename) + ): + raise exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found." + ) + + try: + with open(credentials_filename, "r") as f: + info = json.load(f) + credentials_content = ( + (info.get("type") == "service_account") and info or None + ) + + from google.oauth2 import service_account + + credentials = service_account.IDTokenCredentials.from_service_account_info( + credentials_content, target_audience=audience + ) + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found.", + caught_exc, + ) + six.raise_from(new_exc, caught_exc) + + credentials.refresh(request) + return credentials.token diff --git a/setup.py b/setup.py index 8ae5e090c..0930fd8b5 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ with io.open("README.rst", "r") as fh: long_description = fh.read() -version = "1.12.0" +version = "1.13.0" setup( name="google-auth", diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index 6e66eb4ed..14cd3db8e 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -200,7 +200,7 @@ def default_explicit_service_account(session): session.env[EXPECT_PROJECT_ENV] = "1" session.install(*TEST_DEPENDENCIES) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py") + session.run("pytest", "test_default.py", "test_id_token.py") @nox.session(python=PYTHON_VERSIONS) diff --git a/system_tests/test_compute_engine.py b/system_tests/test_compute_engine.py index bcfdfd604..b0d42f362 100644 --- a/system_tests/test_compute_engine.py +++ b/system_tests/test_compute_engine.py @@ -20,6 +20,9 @@ from google.auth import exceptions from google.auth import jwt from google.auth.compute_engine import _metadata +import google.oauth2.id_token + +AUDIENCE = "https://pubsub.googleapis.com" @pytest.fixture(autouse=True) @@ -53,10 +56,17 @@ def test_default(verify_refresh): def test_id_token_from_metadata(http_request): credentials = compute_engine.IDTokenCredentials( - http_request, "target_audience", use_metadata_identity_endpoint=True + http_request, AUDIENCE, use_metadata_identity_endpoint=True ) credentials.refresh(http_request) _, payload, _, _ = jwt._unverified_decode(credentials.token) - assert payload["aud"] == "target_audience" + assert payload["aud"] == AUDIENCE assert payload["exp"] == credentials.expiry + + +def test_fetch_id_token(http_request): + token = google.oauth2.id_token.fetch_id_token(http_request, AUDIENCE) + + _, payload, _, _ = jwt._unverified_decode(token) + assert payload["aud"] == AUDIENCE diff --git a/system_tests/test_id_token.py b/system_tests/test_id_token.py new file mode 100644 index 000000000..b07cefc18 --- /dev/null +++ b/system_tests/test_id_token.py @@ -0,0 +1,25 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +from google.auth import jwt +import google.oauth2.id_token + + +def test_fetch_id_token(http_request): + audience = "https://pubsub.googleapis.com" + token = google.oauth2.id_token.fetch_id_token(http_request, audience) + + _, payload, _, _ = jwt._unverified_decode(token) + assert payload["aud"] == audience diff --git a/system_tests/test_mtls_http.py b/system_tests/test_mtls_http.py index e7ea0b242..1fd80311d 100644 --- a/system_tests/test_mtls_http.py +++ b/system_tests/test_mtls_http.py @@ -14,6 +14,7 @@ import json from os import path +import time import google.auth import google.auth.credentials @@ -42,6 +43,9 @@ def test_requests(): # supposed to be created. assert authed_session.is_mtls == check_context_aware_metadata() + # Sleep 1 second to avoid 503 error. + time.sleep(1) + if authed_session.is_mtls: response = authed_session.get(MTLS_ENDPOINT.format(project_id)) else: @@ -63,6 +67,9 @@ def test_urllib3(): # supposed to be created. assert is_mtls == check_context_aware_metadata() + # Sleep 1 second to avoid 503 error. + time.sleep(1) + if is_mtls: response = authed_http.request("GET", MTLS_ENDPOINT.format(project_id)) else: diff --git a/tests/crypt/test_es256.py b/tests/crypt/test_es256.py new file mode 100644 index 000000000..087ce6e23 --- /dev/null +++ b/tests/crypt/test_es256.py @@ -0,0 +1,131 @@ +# Copyright 2016 Google Inc. +# +# 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 json +import os + +from cryptography.hazmat.primitives.asymmetric import ec +import pytest + +from google.auth import _helpers +from google.auth.crypt import base +from google.auth.crypt import es256 + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# To generate es256_privatekey.pem, es256_privatekey.pub, and +# es256_public_cert.pem: +# $ openssl ecparam -genkey -name prime256v1 -noout -out es256_privatekey.pem +# $ openssl ec -in es256-private-key.pem -pubout -out es256-publickey.pem +# $ openssl req -new -x509 -key es256_privatekey.pem -out \ +# > es256_public_cert.pem + +with open(os.path.join(DATA_DIR, "es256_privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + PKCS1_KEY_BYTES = PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "es256_publickey.pem"), "rb") as fh: + PUBLIC_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es256_public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "es256_service_account.json") + +with open(SERVICE_ACCOUNT_JSON_FILE, "r") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +class TestES256Verifier(object): + def test_verify_success(self): + to_sign = b"foo" + signer = es256.ES256Signer.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_unicode_success(self): + to_sign = u"foo" + signer = es256.ES256Signer.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_failure(self): + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + bad_signature1 = b"" + assert not verifier.verify(b"foo", bad_signature1) + bad_signature2 = b"a" + assert not verifier.verify(b"foo", bad_signature2) + + def test_from_string_pub_key(self): + verifier = es256.ES256Verifier.from_string(PUBLIC_KEY_BYTES) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_key_unicode(self): + public_key = _helpers.from_bytes(PUBLIC_KEY_BYTES) + verifier = es256.ES256Verifier.from_string(public_key) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert(self): + verifier = es256.ES256Verifier.from_string(PUBLIC_CERT_BYTES) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert_unicode(self): + public_cert = _helpers.from_bytes(PUBLIC_CERT_BYTES) + verifier = es256.ES256Verifier.from_string(public_cert) + assert isinstance(verifier, es256.ES256Verifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + +class TestES256Signer(object): + def test_from_string_pkcs1(self): + signer = es256.ES256Signer.from_string(PKCS1_KEY_BYTES) + assert isinstance(signer, es256.ES256Signer) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_pkcs1_unicode(self): + key_bytes = _helpers.from_bytes(PKCS1_KEY_BYTES) + signer = es256.ES256Signer.from_string(key_bytes) + assert isinstance(signer, es256.ES256Signer) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_bogus_key(self): + key_bytes = "bogus-key" + with pytest.raises(ValueError): + es256.ES256Signer.from_string(key_bytes) + + def test_from_service_account_info(self): + signer = es256.ES256Signer.from_service_account_info(SERVICE_ACCOUNT_INFO) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_service_account_info_missing_key(self): + with pytest.raises(ValueError) as excinfo: + es256.ES256Signer.from_service_account_info({}) + + assert excinfo.match(base._JSON_FILE_PRIVATE_KEY) + + def test_from_service_account_file(self): + signer = es256.ES256Signer.from_service_account_file(SERVICE_ACCOUNT_JSON_FILE) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) diff --git a/tests/data/es256_privatekey.pem b/tests/data/es256_privatekey.pem new file mode 100644 index 000000000..5c950b514 --- /dev/null +++ b/tests/data/es256_privatekey.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAIC57aTx5ev4T2HBMQk4fXV09AzLDQ3Ju1uNoEB0LngoAoGCCqGSM49 +AwEHoUQDQgAEsACsrmP6Bp216OCFm73C8W/VRHZWcO8yU/bMwx96f05BkTII3KeJ +z2O0IRAnXfso8K6YsjMuUDGCfj+b1IDIoA== +-----END EC PRIVATE KEY----- diff --git a/tests/data/es256_public_cert.pem b/tests/data/es256_public_cert.pem new file mode 100644 index 000000000..774ca1484 --- /dev/null +++ b/tests/data/es256_public_cert.pem @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE----- +MIIBGDCBwAIJAPUA0H4EQWsdMAoGCCqGSM49BAMCMBUxEzARBgNVBAMMCnVuaXQt +dGVzdHMwHhcNMTkwNTA5MDI1MDExWhcNMTkwNjA4MDI1MDExWjAVMRMwEQYDVQQD +DAp1bml0LXRlc3RzMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsACsrmP6Bp21 +6OCFm73C8W/VRHZWcO8yU/bMwx96f05BkTII3KeJz2O0IRAnXfso8K6YsjMuUDGC +fj+b1IDIoDAKBggqhkjOPQQDAgNHADBEAh8PcDTMyWk8SHqV/v8FLuMbDxdtAsq2 +dwCpuHQwqCcmAiEAnwtkiyieN+8zozaf1P4QKp2mAqNGqua50y3ua5uVotc= +-----END CERTIFICATE----- diff --git a/tests/data/es256_publickey.pem b/tests/data/es256_publickey.pem new file mode 100644 index 000000000..51f2a03fa --- /dev/null +++ b/tests/data/es256_publickey.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsACsrmP6Bp216OCFm73C8W/VRHZW +cO8yU/bMwx96f05BkTII3KeJz2O0IRAnXfso8K6YsjMuUDGCfj+b1IDIoA== +-----END PUBLIC KEY----- diff --git a/tests/data/es256_service_account.json b/tests/data/es256_service_account.json new file mode 100644 index 000000000..dd26719f6 --- /dev/null +++ b/tests/data/es256_service_account.json @@ -0,0 +1,10 @@ +{ + "type": "service_account", + "project_id": "example-project", + "private_key_id": "1", + "private_key": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIAIC57aTx5ev4T2HBMQk4fXV09AzLDQ3Ju1uNoEB0LngoAoGCCqGSM49\nAwEHoUQDQgAEsACsrmP6Bp216OCFm73C8W/VRHZWcO8yU/bMwx96f05BkTII3KeJ\nz2O0IRAnXfso8K6YsjMuUDGCfj+b1IDIoA==\n-----END EC PRIVATE KEY-----", + "client_email": "service-account@example.com", + "client_id": "1234", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token" +} diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index bdb63e9dd..76aa463cb 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -421,3 +421,31 @@ def test_unpickle_old_credentials_pickle(self): ) as f: credentials = pickle.load(f) assert credentials.quota_project_id is None + + +class TestUserAccessTokenCredentials(object): + def test_instance(self): + cred = credentials.UserAccessTokenCredentials() + assert cred._account is None + + cred = cred.with_account("account") + assert cred._account == "account" + + @mock.patch("google.auth._cloud_sdk.get_auth_access_token", autospec=True) + def test_refresh(self, get_auth_access_token): + get_auth_access_token.return_value = "access_token" + cred = credentials.UserAccessTokenCredentials() + cred.refresh(None) + assert cred.token == "access_token" + + @mock.patch( + "google.oauth2.credentials.UserAccessTokenCredentials.apply", autospec=True + ) + @mock.patch( + "google.oauth2.credentials.UserAccessTokenCredentials.refresh", autospec=True + ) + def test_before_request(self, refresh, apply): + cred = credentials.UserAccessTokenCredentials() + cred.before_request(mock.Mock(), "GET", "https://example.com", {}) + refresh.assert_called() + apply.assert_called() diff --git a/tests/oauth2/test_id_token.py b/tests/oauth2/test_id_token.py index 980a8e982..ff858078a 100644 --- a/tests/oauth2/test_id_token.py +++ b/tests/oauth2/test_id_token.py @@ -13,14 +13,21 @@ # limitations under the License. import json +import os import mock import pytest +from google.auth import environment_vars from google.auth import exceptions from google.auth import transport +import google.auth.compute_engine._metadata from google.oauth2 import id_token +SERVICE_ACCOUNT_FILE = os.path.join( + os.path.dirname(__file__), "../data/service_account.json" +) + def make_request(status, data=None): response = mock.create_autospec(transport.Response, instance=True) @@ -114,3 +121,64 @@ def test_verify_firebase_token(verify_token): audience=mock.sentinel.audience, certs_url=id_token._GOOGLE_APIS_CERTS_URL, ) + + +def test_fetch_id_token_from_metadata_server(): + def mock_init(self, request, audience, use_metadata_identity_endpoint): + assert use_metadata_identity_endpoint + self.token = "id_token" + + with mock.patch.multiple( + google.auth.compute_engine.IDTokenCredentials, + __init__=mock_init, + refresh=mock.Mock(), + ): + request = mock.Mock() + token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +def test_fetch_id_token_from_explicit_cred_json_file(mock_init, monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, SERVICE_ACCOUNT_FILE) + + def mock_refresh(self, request): + self.token = "id_token" + + with mock.patch.object( + google.oauth2.service_account.IDTokenCredentials, "refresh", mock_refresh + ): + request = mock.Mock() + token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +def test_fetch_id_token_no_cred_json_file(mock_init, monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + with pytest.raises(exceptions.DefaultCredentialsError): + request = mock.Mock() + id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +def test_fetch_id_token_invalid_cred_file(mock_init, monkeypatch): + not_json_file = os.path.join(os.path.dirname(__file__), "../data/public_cert.pem") + monkeypatch.setenv(environment_vars.CREDENTIALS, not_json_file) + + with pytest.raises(exceptions.DefaultCredentialsError): + request = mock.Mock() + id_token.fetch_id_token(request, "https://pubsub.googleapis.com") diff --git a/tests/test__cloud_sdk.py b/tests/test__cloud_sdk.py index 049ed9978..337760426 100644 --- a/tests/test__cloud_sdk.py +++ b/tests/test__cloud_sdk.py @@ -22,7 +22,7 @@ from google.auth import _cloud_sdk from google.auth import environment_vars -import google.oauth2.credentials +from google.auth import exceptions DATA_DIR = os.path.join(os.path.dirname(__file__), "data") @@ -137,23 +137,33 @@ def test_get_config_path_no_appdata(monkeypatch): assert os.path.split(config_path) == ("G:/\\", _cloud_sdk._CONFIG_DIRECTORY) -def test_load_authorized_user_credentials(): - credentials = _cloud_sdk.load_authorized_user_credentials(AUTHORIZED_USER_FILE_DATA) +@mock.patch("os.name", new="nt") +@mock.patch("subprocess.check_output", autospec=True) +def test_get_auth_access_token_windows(check_output): + check_output.return_value = b"access_token\n" + + token = _cloud_sdk.get_auth_access_token() + assert token == "access_token" + check_output.assert_called_with( + ("gcloud.cmd", "auth", "print-access-token"), stderr=subprocess.STDOUT + ) + - assert isinstance(credentials, google.oauth2.credentials.Credentials) +@mock.patch("subprocess.check_output", autospec=True) +def test_get_auth_access_token_with_account(check_output): + check_output.return_value = b"access_token\n" - assert credentials.token is None - assert credentials._refresh_token == AUTHORIZED_USER_FILE_DATA["refresh_token"] - assert credentials._client_id == AUTHORIZED_USER_FILE_DATA["client_id"] - assert credentials._client_secret == AUTHORIZED_USER_FILE_DATA["client_secret"] - assert ( - credentials._token_uri - == google.oauth2.credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT + token = _cloud_sdk.get_auth_access_token(account="account") + assert token == "access_token" + check_output.assert_called_with( + ("gcloud", "auth", "print-access-token", "--account=account"), + stderr=subprocess.STDOUT, ) -def test_load_authorized_user_credentials_bad_format(): - with pytest.raises(ValueError) as excinfo: - _cloud_sdk.load_authorized_user_credentials({}) +@mock.patch("subprocess.check_output", autospec=True) +def test_get_auth_access_token_with_exception(check_output): + check_output.side_effect = OSError() - assert excinfo.match(r"missing fields") + with pytest.raises(exceptions.UserAccessTokenError): + _cloud_sdk.get_auth_access_token(account="account") diff --git a/tests/test_jwt.py b/tests/test_jwt.py index b0c6e48e9..488aee467 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -37,6 +37,12 @@ with open(os.path.join(DATA_DIR, "other_cert.pem"), "rb") as fh: OTHER_CERT_BYTES = fh.read() +with open(os.path.join(DATA_DIR, "es256_privatekey.pem"), "rb") as fh: + EC_PRIVATE_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es256_public_cert.pem"), "rb") as fh: + EC_PUBLIC_CERT_BYTES = fh.read() + SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") with open(SERVICE_ACCOUNT_JSON_FILE, "r") as fh: @@ -68,8 +74,21 @@ def test_encode_extra_headers(signer): @pytest.fixture -def token_factory(signer): - def factory(claims=None, key_id=None): +def es256_signer(): + return crypt.ES256Signer.from_string(EC_PRIVATE_KEY_BYTES, "1") + + +def test_encode_basic_es256(es256_signer): + test_payload = {"test": "value"} + encoded = jwt.encode(es256_signer, test_payload) + header, payload, _, _ = jwt._unverified_decode(encoded) + assert payload == test_payload + assert header == {"typ": "JWT", "alg": "ES256", "kid": es256_signer.key_id} + + +@pytest.fixture +def token_factory(signer, es256_signer): + def factory(claims=None, key_id=None, use_es256_signer=False): now = _helpers.datetime_to_secs(_helpers.utcnow()) payload = { "aud": "audience@example.com", @@ -86,7 +105,10 @@ def factory(claims=None, key_id=None): signer._key_id = None key_id = None - return jwt.encode(signer, payload, key_id=key_id) + if use_es256_signer: + return jwt.encode(es256_signer, payload, key_id=key_id) + else: + return jwt.encode(signer, payload, key_id=key_id) return factory @@ -98,6 +120,15 @@ def test_decode_valid(token_factory): assert payload["metadata"]["meta"] == "data" +def test_decode_valid_es256(token_factory): + payload = jwt.decode( + token_factory(use_es256_signer=True), certs=EC_PUBLIC_CERT_BYTES + ) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + def test_decode_valid_with_audience(token_factory): payload = jwt.decode( token_factory(), certs=PUBLIC_CERT_BYTES, audience="audience@example.com" @@ -201,6 +232,29 @@ def test_decode_no_key_id(token_factory): assert payload["user"] == "billy bob" +def test_decode_unknown_alg(): + headers = json.dumps({u"kid": u"1", u"alg": u"fakealg"}) + token = b".".join( + map(lambda seg: base64.b64encode(seg.encode("utf-8")), [headers, u"{}", u"sig"]) + ) + + with pytest.raises(ValueError) as excinfo: + jwt.decode(token) + assert excinfo.match(r"fakealg") + + +def test_decode_missing_crytography_alg(monkeypatch): + monkeypatch.delitem(jwt._ALGORITHM_TO_VERIFIER_CLASS, "ES256") + headers = json.dumps({u"kid": u"1", u"alg": u"ES256"}) + token = b".".join( + map(lambda seg: base64.b64encode(seg.encode("utf-8")), [headers, u"{}", u"sig"]) + ) + + with pytest.raises(ValueError) as excinfo: + jwt.decode(token) + assert excinfo.match(r"cryptography") + + def test_roundtrip_explicit_key_id(token_factory): token = token_factory(key_id="3") certs = {"2": OTHER_CERT_BYTES, "3": PUBLIC_CERT_BYTES} diff --git a/tests/transport/test_grpc.py b/tests/transport/test_grpc.py index 23e62a213..5c61f96e1 100644 --- a/tests/transport/test_grpc.py +++ b/tests/transport/test_grpc.py @@ -21,6 +21,7 @@ from google.auth import _helpers from google.auth import credentials +from google.auth import exceptions from google.auth import transport try: @@ -315,7 +316,7 @@ def test_get_client_ssl_credentials_failure( # Mock that client cert and key are not loaded and exception is raised. mock_get_client_ssl_credentials.side_effect = ValueError() - with pytest.raises(ValueError): + with pytest.raises(exceptions.MutualTLSChannelError): assert google.auth.transport.grpc.SslCredentials().ssl_credentials def test_get_client_ssl_credentials_success( diff --git a/tests/transport/test_requests.py b/tests/transport/test_requests.py index 3f3e14c05..d6770de73 100644 --- a/tests/transport/test_requests.py +++ b/tests/transport/test_requests.py @@ -14,6 +14,7 @@ import datetime import functools +import sys import freezegun import mock @@ -23,6 +24,7 @@ import requests.adapters from six.moves import http_client +from google.auth import exceptions import google.auth.credentials import google.auth.transport._mtls_helper import google.auth.transport.requests @@ -414,3 +416,21 @@ def test_configure_mtls_channel_non_mtls( # Assert _MutualTlsAdapter constructor is not called. mock_adapter_ctor.assert_not_called() + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): + mock_get_client_cert_and_key.side_effect = ValueError() + + auth_session = google.auth.transport.requests.AuthorizedSession( + credentials=mock.Mock() + ) + with pytest.raises(exceptions.MutualTLSChannelError): + auth_session.configure_mtls_channel() + + mock_get_client_cert_and_key.return_value = (False, None, None) + with mock.patch.dict("sys.modules"): + sys.modules["OpenSSL"] = None + with pytest.raises(exceptions.MutualTLSChannelError): + auth_session.configure_mtls_channel() diff --git a/tests/transport/test_urllib3.py b/tests/transport/test_urllib3.py index 0452e9187..a25fcd7d9 100644 --- a/tests/transport/test_urllib3.py +++ b/tests/transport/test_urllib3.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + import mock import OpenSSL import pytest from six.moves import http_client import urllib3 +from google.auth import exceptions import google.auth.credentials import google.auth.transport._mtls_helper import google.auth.transport.urllib3 @@ -221,3 +224,21 @@ def test_configure_mtls_channel_non_mtls( assert not is_mtls mock_get_client_cert_and_key.assert_called_once() mock_make_mutual_tls_http.assert_not_called() + + @mock.patch( + "google.auth.transport._mtls_helper.get_client_cert_and_key", autospec=True + ) + def test_configure_mtls_channel_exceptions(self, mock_get_client_cert_and_key): + authed_http = google.auth.transport.urllib3.AuthorizedHttp( + credentials=mock.Mock() + ) + + mock_get_client_cert_and_key.side_effect = ValueError() + with pytest.raises(exceptions.MutualTLSChannelError): + authed_http.configure_mtls_channel() + + mock_get_client_cert_and_key.return_value = (False, None, None) + with mock.patch.dict("sys.modules"): + sys.modules["OpenSSL"] = None + with pytest.raises(exceptions.MutualTLSChannelError): + authed_http.configure_mtls_channel()