Repository: GoogleCloudPlatform/google-auth-library-python
Pull request #224 (2017-11-29 06:03:26)

Improve documentation around ID Tokens
Includes a section on how to do caching, as part of #218 .

I've never used Sphinx for documentation before, and suggestions on clearer/more precise wording are always appreciated


Patch:
diff --git a/.gitignore b/.gitignore
index 1f65cf3..c6ab9e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ docs/_build
 .nox/
 .tox/
 .cache/
+.pytest_cache/
 
 # Django test database
 db.sqlite3
diff --git a/.travis.yml b/.travis.yml
index 8fc00d5..8c67288 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,7 +2,7 @@ language: python
 sudo: false
 matrix:
   include:
-  - python: 3.5
+  - python: 3.6
     env: TOXENV=lint
   - python: 3.6
     env: TOXENV=docs
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index d359cf6..58052f5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,16 @@
 Changelog
 =========
 
+v1.3.0
+------
+
+- Added ``google.oauth2.credentials.Credentials.from_authorized_user_file`` (#226)
+- Dropped direct pyasn1 dependency in favor of letting ``pyasn1-modules`` specify the right version. (#230)
+- ``default()`` now checks for the project ID environment var before warning about missing project ID. (#227)
+- Fixed the docstrings for ``has_scopes()`` and ``with_scopes()``. (#228)
+- Fixed example in docstring for ``ReadOnlyScoped``. (#219)
+- Made ``transport.requests`` use timeouts and retries to improve reliability. (#220)
+
 v1.2.1
 ------
 
diff --git a/docs/index.rst b/docs/index.rst
index 1b5f5d6..af2c804 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -14,6 +14,7 @@ also provides integration with several HTTP libraries.
 
 - Support for Google :func:`Application Default Credentials <google.auth.default>`.
 - Support for signing and verifying :mod:`JWTs <google.auth.jwt>`.
+- Support for verifying and decoding :mod:`ID Tokens <google.oauth2.id_token>`.
 - Support for Google :mod:`Service Account credentials <google.oauth2.service_account>`.
 - Support for :mod:`Google Compute Engine credentials <google.auth.compute_engine>`.
 - Support for :mod:`Google App Engine standard credentials <google.auth.app_engine>`.
diff --git a/google/auth/_cloud_sdk.py b/google/auth/_cloud_sdk.py
index 898c6ec..31be5e7 100644
--- a/google/auth/_cloud_sdk.py
+++ b/google/auth/_cloud_sdk.py
@@ -18,13 +18,9 @@ import json
 import os
 import subprocess
 
-import six
-
 from google.auth import environment_vars
 import google.oauth2.credentials
 
-# The Google OAuth 2.0 token endpoint. Used for authorized user credentials.
-_GOOGLE_OAUTH2_TOKEN_ENDPOINT = 'https://accounts.google.com/o/oauth2/token'
 
 # The ~/.config subdirectory containing gcloud credentials.
 _CONFIG_DIRECTORY = 'gcloud'
@@ -94,20 +90,8 @@ def load_authorized_user_credentials(info):
     Raises:
         ValueError: if the info is in the wrong format or missing data.
     """
-    keys_needed = set(('refresh_token', 'client_id', 'client_secret'))
-    missing = keys_needed.difference(six.iterkeys(info))
-
-    if missing:
-        raise ValueError(
-            'Authorized user info was not in the expected format, missing '
-            'fields {}.'.format(', '.join(missing)))
-
-    return google.oauth2.credentials.Credentials(
-        None,  # No access token, must be refreshed.
-        refresh_token=info['refresh_token'],
-        token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT,
-        client_id=info['client_id'],
-        client_secret=info['client_secret'])
+    return google.oauth2.credentials.Credentials.from_authorized_user_info(
+        info)
 
 
 def get_project_id():
diff --git a/google/auth/_default.py b/google/auth/_default.py
index 5410a4c..d63dcee 100644
--- a/google/auth/_default.py
+++ b/google/auth/_default.py
@@ -129,12 +129,6 @@ def _get_gcloud_sdk_credentials():
     if not project_id:
         project_id = _cloud_sdk.get_project_id()
 
-    if not project_id:
-        _LOGGER.warning(
-            'No project ID could be determined from the Cloud SDK '
-            'configuration. Consider running `gcloud config set project` or '
-            'setting the %s environment variable', environment_vars.PROJECT)
-
     return credentials, project_id
 
 
@@ -147,12 +141,6 @@ def _get_explicit_environ_credentials():
         credentials, project_id = _load_credentials_from_file(
             os.environ[environment_vars.CREDENTIALS])
 
-        if not project_id:
-            _LOGGER.warning(
-                'No project ID could be determined from the credentials at %s '
-                'Consider setting the %s environment variable',
-                environment_vars.CREDENTIALS, environment_vars.PROJECT)
-
         return credentials, project_id
 
     else:
@@ -188,10 +176,6 @@ def _get_gce_credentials(request=None):
         try:
             project_id = _metadata.get_project_id(request=request)
         except exceptions.TransportError:
-            _LOGGER.warning(
-                'No project ID could be determined from the Compute Engine '
-                'metadata service. Consider setting the %s environment '
-                'variable.', environment_vars.PROJECT)
             project_id = None
 
         return compute_engine.Credentials(), project_id
@@ -287,6 +271,13 @@ def default(scopes=None, request=None):
         credentials, project_id = checker()
         if credentials is not None:
             credentials = with_scopes_if_required(credentials, scopes)
-            return credentials, explicit_project_id or project_id
+            effective_project_id = explicit_project_id or project_id
+            if not effective_project_id:
+                _LOGGER.warning(
+                    'No project ID could be determined. Consider running '
+                    '`gcloud config set project` or setting the %s '
+                    'environment variable',
+                    environment_vars.PROJECT)
+            return credentials, effective_project_id
 
     raise exceptions.DefaultCredentialsError(_HELP_MESSAGE)
diff --git a/google/auth/app_engine.py b/google/auth/app_engine.py
index fa13f8e..f47dae1 100644
--- a/google/auth/app_engine.py
+++ b/google/auth/app_engine.py
@@ -136,7 +136,7 @@ class Credentials(credentials.Scoped, credentials.Signing,
 
     @_helpers.copy_docstring(credentials.Scoped)
     def with_scopes(self, scopes):
-        return Credentials(
+        return self.__class__(
             scopes=scopes, service_account_id=self._service_account_id)
 
     @_helpers.copy_docstring(credentials.Signing)
diff --git a/google/auth/credentials.py b/google/auth/credentials.py
index db2869c..64b3338 100644
--- a/google/auth/credentials.py
+++ b/google/auth/credentials.py
@@ -209,6 +209,9 @@ class ReadOnlyScoped(object):
         .. warning: This method is not guaranteed to be accurate if the
             credentials are :attr:`~Credentials.invalid`.
 
+        Args:
+            scopes (Sequence[str]): The list of scopes to check.
+
         Returns:
             bool: True if the credentials have the given scopes.
         """
@@ -248,7 +251,8 @@ class Scoped(ReadOnlyScoped):
         """Create a copy of these credentials with the specified scopes.
 
         Args:
-            scopes (Sequence[str]): The list of scopes to request.
+            scopes (Sequence[str]): The list of scopes to attach to the
+                current credentials.
 
         Raises:
             NotImplementedError: If the credentials' scopes can not be changed.
diff --git a/google/auth/crypt/_cryptography_rsa.py b/google/auth/crypt/_cryptography_rsa.py
new file mode 100644
index 0000000..153e688
--- /dev/null
+++ b/google/auth/crypt/_cryptography_rsa.py
@@ -0,0 +1,137 @@
+# 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.
+
+"""RSA verifier and signer that use the ``cryptography`` library.
+
+This is a much faster implementation than the default (in
+``google.auth.crypt._python_rsa``), which depends on the pure-Python
+``rsa`` 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 padding
+import cryptography.x509
+
+from google.auth import _helpers
+from google.auth.crypt import base
+
+
+_CERTIFICATE_MARKER = b'-----BEGIN CERTIFICATE-----'
+_BACKEND = backends.default_backend()
+_PADDING = padding.PKCS1v15()
+_SHA256 = hashes.SHA256()
+
+
+class RSAVerifier(base.Verifier):
+    """Verifies RSA cryptographic signatures using public keys.
+
+    Args:
+        public_key (
+                cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey):
+            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, _PADDING, _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 RSASigner(base.Signer, base.FromServiceAccountMixin):
+    """Signs messages with an RSA private key.
+
+    Args:
+        private_key (
+                cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
+            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, _PADDING, _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/crypt/_helpers.py b/google/auth/crypt/_helpers.py
new file mode 100644
index 0000000..e69de29
diff --git a/google/auth/crypt/_python_rsa.py b/google/auth/crypt/_python_rsa.py
index 1f6384d..44aa791 100644
--- a/google/auth/crypt/_python_rsa.py
+++ b/google/auth/crypt/_python_rsa.py
@@ -21,9 +21,6 @@ certificates. There is no support for p12 files.
 
 from __future__ import absolute_import
 
-import io
-import json
-
 from pyasn1.codec.der import decoder
 from pyasn1_modules import pem
 from pyasn1_modules.rfc2459 import Certificate
@@ -41,8 +38,6 @@ _PKCS1_MARKER = ('-----BEGIN RSA PRIVATE KEY-----',
 _PKCS8_MARKER = ('-----BEGIN PRIVATE KEY-----',
                  '-----END PRIVATE KEY-----')
 _PKCS8_SPEC = PrivateKeyInfo()
-_JSON_FILE_PRIVATE_KEY = 'private_key'
-_JSON_FILE_PRIVATE_KEY_ID = 'private_key_id'
 
 
 def _bit_list_to_bytes(bit_list):
@@ -119,7 +114,7 @@ class RSAVerifier(base.Verifier):
         return cls(pubkey)
 
 
-class RSASigner(base.Signer):
+class RSASigner(base.Signer, base.FromServiceAccountMixin):
     """Signs messages with an RSA private key.
 
     Args:
@@ -179,43 +174,3 @@ class RSASigner(base.Signer):
             raise ValueError('No key could be detected.')
 
         return cls(private_key, key_id=key_id)
-
-    @classmethod
-    def from_service_account_info(cls, info):
-        """Creates a Signer instance instance from a dictionary containing
-        service account info in Google format.
-
-        Args:
-            info (Mapping[str, str]): The service account info in Google
-                format.
-
-        Returns:
-            google.auth.crypt.Signer: The constructed signer.
-
-        Raises:
-            ValueError: If the info is not in the expected format.
-        """
-        if _JSON_FILE_PRIVATE_KEY not in info:
-            raise ValueError(
-                'The private_key field was not found in the service account '
-                'info.')
-
-        return cls.from_string(
-            info[_JSON_FILE_PRIVATE_KEY],
-            info.get(_JSON_FILE_PRIVATE_KEY_ID))
-
-    @classmethod
-    def from_service_account_file(cls, filename):
-        """Creates a Signer instance from a service account .json file
-        in Google format.
-
-        Args:
-            filename (str): The path to the service account .json file.
-
-        Returns:
-            google.auth.crypt.Signer: The constructed signer.
-        """
-        with io.open(filename, 'r', encoding='utf-8') as json_file:
-            data = json.load(json_file)
-
-        return cls.from_service_account_info(data)
diff --git a/google/auth/crypt/base.py b/google/auth/crypt/base.py
index 05c5a2b..c6c0427 100644
--- a/google/auth/crypt/base.py
+++ b/google/auth/crypt/base.py
@@ -15,10 +15,16 @@
 """Base classes for cryptographic signers and verifiers."""
 
 import abc
+import io
+import json
 
 import six
 
 
+_JSON_FILE_PRIVATE_KEY = 'private_key'
+_JSON_FILE_PRIVATE_KEY_ID = 'private_key_id'
+
+
 @six.add_metaclass(abc.ABCMeta)
 class Verifier(object):
     """Abstract base class for crytographic signature verifiers."""
@@ -62,3 +68,64 @@ class Signer(object):
         # pylint: disable=missing-raises-doc,redundant-returns-doc
         # (pylint doesn't recognize that this is abstract)
         raise NotImplementedError('Sign must be implemented')
+
+
+@six.add_metaclass(abc.ABCMeta)
+class FromServiceAccountMixin(object):
+    """Mix-in to enable factory constructors for a Signer."""
+
+    @abc.abstractmethod
+    def from_string(cls, key, key_id=None):
+        """Construct an Signer instance from a private key string.
+
+        Args:
+            key (str): Private key as a string.
+            key_id (str): An optional key id used to identify the private key.
+
+        Returns:
+            google.auth.crypt.Signer: The constructed signer.
+
+        Raises:
+            ValueError: If the key cannot be parsed.
+        """
+        raise NotImplementedError('from_string must be implemented')
+
+    @classmethod
+    def from_service_account_info(cls, info):
+        """Creates a Signer instance instance from a dictionary containing
+        service account info in Google format.
+
+        Args:
+            info (Mapping[str, str]): The service account info in Google
+                format.
+
+        Returns:
+            google.auth.crypt.Signer: The constructed signer.
+
+        Raises:
+            ValueError: If the info is not in the expected format.
+        """
+        if _JSON_FILE_PRIVATE_KEY not in info:
+            raise ValueError(
+                'The private_key field was not found in the service account '
+                'info.')
+
+        return cls.from_string(
+            info[_JSON_FILE_PRIVATE_KEY],
+            info.get(_JSON_FILE_PRIVATE_KEY_ID))
+
+    @classmethod
+    def from_service_account_file(cls, filename):
+        """Creates a Signer instance from a service account .json file
+        in Google format.
+
+        Args:
+            filename (str): The path to the service account .json file.
+
+        Returns:
+            google.auth.crypt.Signer: The constructed signer.
+        """
+        with io.open(filename, 'r', encoding='utf-8') as json_file:
+            data = json.load(json_file)
+
+        return cls.from_service_account_info(data)
diff --git a/google/auth/crypt/rsa.py b/google/auth/crypt/rsa.py
index d0bf2a0..5da1ba6 100644
--- a/google/auth/crypt/rsa.py
+++ b/google/auth/crypt/rsa.py
@@ -14,7 +14,17 @@
 
 """RSA cryptography signer and verifier."""
 
-from google.auth.crypt import _python_rsa
 
-RSASigner = _python_rsa.RSASigner
-RSAVerifier = _python_rsa.RSAVerifier
+try:
+    # Prefer cryptograph-based RSA implementation.
+    from google.auth.crypt import _cryptography_rsa
+
+    RSASigner = _cryptography_rsa.RSASigner
+    RSAVerifier = _cryptography_rsa.RSAVerifier
+except ImportError:  # pragma: NO COVER
+    # Fallback to pure-python RSA implementation if cryptography is
+    # unavailable.
+    from google.auth.crypt import _python_rsa
+
+    RSASigner = _python_rsa.RSASigner
+    RSAVerifier = _python_rsa.RSAVerifier
diff --git a/google/auth/jwt.py b/google/auth/jwt.py
index 0253376..6957374 100644
--- a/google/auth/jwt.py
+++ b/google/auth/jwt.py
@@ -438,7 +438,7 @@ class Credentials(google.auth.credentials.Signing,
         new_additional_claims = copy.deepcopy(self._additional_claims)
         new_additional_claims.update(additional_claims or {})
 
-        return Credentials(
+        return self.__class__(
             self._signer,
             issuer=issuer if issuer is not None else self._issuer,
             subject=subject if subject is not None else self._subject,
@@ -643,7 +643,7 @@ class OnDemandCredentials(
         new_additional_claims = copy.deepcopy(self._additional_claims)
         new_additional_claims.update(additional_claims or {})
 
-        return OnDemandCredentials(
+        return self.__class__(
             self._signer,
             issuer=issuer if issuer is not None else self._issuer,
             subject=subject if subject is not None else self._subject,
diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py
index 66251df..dc35be2 100644
--- a/google/oauth2/_client.py
+++ b/google/oauth2/_client.py
@@ -32,6 +32,7 @@ from six.moves import urllib
 
 from google.auth import _helpers
 from google.auth import exceptions
+from google.auth import jwt
 
 _URLENCODED_CONTENT_TYPE = 'application/x-www-form-urlencoded'
 _JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
@@ -155,6 +156,51 @@ def jwt_grant(request, token_uri, assertion):
     return access_token, expiry, response_data
 
 
+def id_token_jwt_grant(request, token_uri, assertion):
+    """Implements the JWT Profile for OAuth 2.0 Authorization Grants, but
+    requests an OpenID Connect ID Token instead of an access token.
+
+    This is a variant on the standard JWT Profile that is currently unique
+    to Google. This was added for the benefit of authenticating to services
+    that require ID Tokens instead of access tokens or JWT bearer tokens.
+
+    Args:
+        request (google.auth.transport.Request): A callable used to make
+            HTTP requests.
+        token_uri (str): The OAuth 2.0 authorization server's token endpoint
+            URI.
+        assertion (str): JWT token signed by a service account. The token's
+            payload must include a ``target_audience`` claim.
+
+    Returns:
+        Tuple[str, Optional[datetime], Mapping[str, str]]:
+            The (encoded) Open ID Connect ID Token, expiration, and additional
+            data returned by the endpoint.
+
+    Raises:
+        google.auth.exceptions.RefreshError: If the token endpoint returned
+            an error.
+    """
+    body = {
+        'assertion': assertion,
+        'grant_type': _JWT_GRANT_TYPE,
+    }
+
+    response_data = _token_endpoint_request(request, token_uri, body)
+
+    try:
+        id_token = response_data['id_token']
+    except KeyError as caught_exc:
+        new_exc = exceptions.RefreshError(
+            'No ID token in response.', response_data)
+        six.raise_from(new_exc, caught_exc)
+
+    payload = jwt.decode(id_token, verify=False)
+    expiry = datetime.datetime.utcfromtimestamp(payload['exp'])
+
+    return id_token, expiry, response_data
+
+
 def refresh_grant(request, token_uri, refresh_token, client_id, client_secret):
     """Implements the OAuth 2.0 refresh token grant.
 
diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py
index f1df887..24b3a3e 100644
--- a/google/oauth2/credentials.py
+++ b/google/oauth2/credentials.py
@@ -31,11 +31,20 @@ Authorization Code grant flow.
 .. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1
 """
 
+import io
+import json
+
+import six
+
 from google.auth import _helpers
 from google.auth import credentials
 from google.oauth2 import _client
 
 
+# The Google OAuth 2.0 token endpoint. Used for authorized user credentials.
+_GOOGLE_OAUTH2_TOKEN_ENDPOINT = 'https://accounts.google.com/o/oauth2/token'
+
+
 class Credentials(credentials.ReadOnlyScoped, credentials.Credentials):
     """Credentials using OAuth 2.0 access and refresh tokens."""
 
@@ -120,3 +129,56 @@ class Credentials(credentials.ReadOnlyScoped, credentials.Credentials):
         self.expiry = expiry
         self._refresh_token = refresh_token
         self._id_token = grant_response.get('id_token')
+
+    @classmethod
+    def from_authorized_user_info(cls, info, scopes=None):
+        """Creates a Credentials instance from parsed authorized user info.
+
+        Args:
+            info (Mapping[str, str]): The authorized user info in Google
+                format.
+            scopes (Sequence[str]): Optional list of scopes to include in the
+                credentials.
+
+        Returns:
+            google.oauth2.credentials.Credentials: The constructed
+                credentials.
+
+        Raises:
+            ValueError: If the info is not in the expected format.
+        """
+        keys_needed = set(('refresh_token', 'client_id', 'client_secret'))
+        missing = keys_needed.difference(six.iterkeys(info))
+
+        if missing:
+            raise ValueError(
+                'Authorized user info was not in the expected format, missing '
+                'fields {}.'.format(', '.join(missing)))
+
+        return Credentials(
+            None,  # No access token, must be refreshed.
+            refresh_token=info['refresh_token'],
+            token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT,
+            scopes=scopes,
+            client_id=info['client_id'],
+            client_secret=info['client_secret'])
+
+    @classmethod
+    def from_authorized_user_file(cls, filename, scopes=None):
+        """Creates a Credentials instance from an authorized user json file.
+
+        Args:
+            filename (str): The path to the authorized user json file.
+            scopes (Sequence[str]): Optional list of scopes to include in the
+                credentials.
+
+        Returns:
+            google.oauth2.credentials.Credentials: The constructed
+                credentials.
+
+        Raises:
+            ValueError: If the file is not in the expected format.
+        """
+        with io.open(filename, 'r', encoding='utf-8') as json_file:
+            data = json.load(json_file)
+            return cls.from_authorized_user_info(data, scopes)
diff --git a/google/oauth2/id_token.py b/google/oauth2/id_token.py
index fa96fc0..208ab62 100644
--- a/google/oauth2/id_token.py
+++ b/google/oauth2/id_token.py
@@ -12,7 +12,51 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""Google ID Token helpers."""
+"""Google ID Token helpers.
+
+Provides support for verifying `OpenID Connect ID Tokens`_, especially ones
+generated by Google infrastructure.
+
+To parse and verify an ID Token issued by Google's OAuth 2.0 authorization
+server use :func:`verify_oauth2_token`. To verify an ID Token issued by
+Firebase, use :func:`verify_firebase_token`.
+
+A general purpose ID Token verifier is available as :func:`verify_token`.
+
+Example::
+
+    from google.oauth2 import id_token
+    from google.auth.transport import requests
+
+    request = requests.Request()
+
+    id_info = id_token.verify_oauth2_token(
+        token, request, 'my-client-id.example.com')
+
+    if id_info['iss'] != 'https://accounts.google.com':
+        raise ValueError('Wrong issuer.')
+
+    userid = id_info['sub']
+
+By default, this will re-fetch certificates for each verification. Because
+Google's public keys are only changed infrequently (on the order of once per
+day), you may wish to take advantage of caching to reduce latency and the
+potential for network errors. This can be accomplished using an external
+library like `CacheControl`_ to create a cache-aware
+:class:`google.auth.transport.Request`::
+
+    import cachecontrol
+    import google.auth.transport.requests
+    import requests
+
+    session = requests.session()
+    cached_session = cachecontrol.CacheControl(session)
+    request = google.auth.transport.requests.Request(session=cached_session)
+
+.. _OpenID Connect ID Token:
+    http://openid.net/specs/openid-connect-core-1_0.html#IDToken
+.. _CacheControl: https://cachecontrol.readthedocs.io
+"""
 
 import json
 
diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py
index 54bd8d6..c60c565 100644
--- a/google/oauth2/service_account.py
+++ b/google/oauth2/service_account.py
@@ -230,7 +230,7 @@ class Credentials(credentials.Signing,
 
     @_helpers.copy_docstring(credentials.Scoped)
     def with_scopes(self, scopes):
-        return Credentials(
+        return self.__class__(
             self._signer,
             service_account_email=self._service_account_email,
             scopes=scopes,
@@ -249,7 +249,7 @@ class Credentials(credentials.Signing,
             google.auth.service_account.Credentials: A new credentials
                 instance.
         """
-        return Credentials(
+        return self.__class__(
             self._signer,
             service_account_email=self._service_account_email,
             scopes=self._scopes,
@@ -273,7 +273,7 @@ class Credentials(credentials.Signing,
         new_additional_claims = copy.deepcopy(self._additional_claims)
         new_additional_claims.update(additional_claims or {})
 
-        return Credentials(
+        return self.__class__(
             self._signer,
             service_account_email=self._service_account_email,
             scopes=self._scopes,
@@ -336,3 +336,207 @@ class Credentials(credentials.Signing,
     @_helpers.copy_docstring(credentials.Signing)
     def signer_email(self):
         return self._service_account_email
+
+
+class IDTokenCredentials(credentials.Signing, credentials.Credentials):
+    """Open ID Connect ID Token-based service account credentials.
+
+    These credentials are largely similar to :class:`.Credentials`, but instead
+    of using an OAuth 2.0 Access Token as the bearer token, they use an Open
+    ID Connect ID Token as the bearer token. These credentials are useful when
+    communicating to services that require ID Tokens and can not accept access
+    tokens.
+
+    Usually, you'll create these credentials with one of the helper
+    constructors. To create credentials using a Google service account
+    private key JSON file::
+
+        credentials = (
+            service_account.IDTokenCredentials.from_service_account_file(
+                'service-account.json'))
+
+    Or if you already have the service account file loaded::
+
+        service_account_info = json.load(open('service_account.json'))
+        credentials = (
+            service_account.IDTokenCredentials.from_service_account_info(
+                service_account_info))
+
+    Both helper methods pass on arguments to the constructor, so you can
+    specify additional scopes and a subject if necessary::
+
+        credentials = (
+            service_account.IDTokenCredentials.from_service_account_file(
+                'service-account.json',
+                scopes=['email'],
+                subject='user@example.com'))
+`
+    The credentials are considered immutable. If you want to modify the scopes
+    or the subject used for delegation, use :meth:`with_scopes` or
+    :meth:`with_subject`::
+
+        scoped_credentials = credentials.with_scopes(['email'])
+        delegated_credentials = credentials.with_subject(subject)
+
+    """
+    def __init__(self, signer, service_account_email, token_uri,
+                 target_audience, additional_claims=None):
+        """
+        Args:
+            signer (google.auth.crypt.Signer): The signer used to sign JWTs.
+            service_account_email (str): The service account's email.
+            token_uri (str): The OAuth 2.0 Token URI.
+            target_audience (str): The intended audience for these credentials,
+                used when requesting the ID Token. The ID Token's ``aud`` claim
+                will be set to this string.
+            additional_claims (Mapping[str, str]): Any additional claims for
+                the JWT assertion used in the authorization grant.
+
+        .. note:: Typically one of the helper constructors
+            :meth:`from_service_account_file` or
+            :meth:`from_service_account_info` are used instead of calling the
+            constructor directly.
+        """
+        super(IDTokenCredentials, self).__init__()
+        self._signer = signer
+        self._service_account_email = service_account_email
+        self._token_uri = token_uri
+        self._target_audience = target_audience
+
+        if additional_claims is not None:
+            self._additional_claims = additional_claims
+        else:
+            self._additional_claims = {}
+
+    @classmethod
+    def _from_signer_and_info(cls, signer, info, **kwargs):
+        """Creates a credentials instance from a signer and service account
+        info.
+
+        Args:
+            signer (google.auth.crypt.Signer): The signer used to sign JWTs.
+            info (Mapping[str, str]): The service account info.
+            kwargs: Additional arguments to pass to the constructor.
+
+        Returns:
+            google.auth.jwt.IDTokenCredentials: The constructed credentials.
+
+        Raises:
+            ValueError: If the info is not in the expected format.
+        """
+        kwargs.setdefault('service_account_email', info['client_email'])
+        kwargs.setdefault('token_uri', info['token_uri'])
+        return cls(signer, **kwargs)
+
+    @classmethod
+    def from_service_account_info(cls, info, **kwargs):
+        """Creates a credentials instance from parsed service account info.
+
+        Args:
+            info (Mapping[str, str]): The service account info in Google
+                format.
+            kwargs: Additional arguments to pass to the constructor.
+
+        Returns:
+            google.auth.service_account.IDTokenCredentials: The constructed
+                credentials.
+
+        Raises:
+            ValueError: If the info is not in the expected format.
+        """
+        signer = _service_account_info.from_dict(
+            info, require=['client_email', 'token_uri'])
+        return cls._from_signer_and_info(signer, info, **kwargs)
+
+    @classmethod
+    def from_service_account_file(cls, filename, **kwargs):
+        """Creates a credentials instance from a service account json file.
+
+        Args:
+            filename (str): The path to the service account json file.
+            kwargs: Additional arguments to pass to the constructor.
+
+        Returns:
+            google.auth.service_account.IDTokenCredentials: The constructed
+                credentials.
+        """
+        info, signer = _service_account_info.from_filename(
+            filename, require=['client_email', 'token_uri'])
+        return cls._from_signer_and_info(signer, info, **kwargs)
+
+    def with_target_audience(self, target_audience):
+        """Create a copy of these credentials with the specified target
+        audience.
+
+        Args:
+            target_audience (str): The intended audience for these credentials,
+            used when requesting the ID Token.
+
+        Returns:
+            google.auth.service_account.IDTokenCredentials: A new credentials
+                instance.
+        """
+        return self.__class__(
+            self._signer,
+            service_account_email=self._service_account_email,
+            token_uri=self._token_uri,
+            target_audience=target_audience,
+            additional_claims=self._additional_claims.copy())
+
+    def _make_authorization_grant_assertion(self):
+        """Create the OAuth 2.0 assertion.
+
+        This assertion is used during the OAuth 2.0 grant to acquire an
+        ID token.
+
+        Returns:
+            bytes: The authorization grant assertion.
+        """
+        now = _helpers.utcnow()
+        lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS)
+        expiry = now + lifetime
+
+        payload = {
+            'iat': _helpers.datetime_to_secs(now),
+            'exp': _helpers.datetime_to_secs(expiry),
+            # The issuer must be the service account email.
+            'iss': self.service_account_email,
+            # The audience must be the auth token endpoint's URI
+            'aud': self._token_uri,
+            # The target audience specifies which service the ID token is
+            # intended for.
+            'target_audience': self._target_audience
+        }
+
+        payload.update(self._additional_claims)
+
+        token = jwt.encode(self._signer, payload)
+
+        return token
+
+    @_helpers.copy_docstring(credentials.Credentials)
+    def refresh(self, request):
+        assertion = self._make_authorization_grant_assertion()
+        access_token, expiry, _ = _client.id_token_jwt_grant(
+            request, self._token_uri, assertion)
+        self.token = access_token
+        self.expiry = expiry
+
+    @property
+    def service_account_email(self):
+        """The service account email."""
+        return self._service_account_email
+
+    @_helpers.copy_docstring(credentials.Signing)
+    def sign_bytes(self, message):
+        return self._signer.sign(message)
+
+    @property
+    @_helpers.copy_docstring(credentials.Signing)
+    def signer(self):
+        return self._signer
+
+    @property
+    @_helpers.copy_docstring(credentials.Signing)
+    def signer_email(self):
+        return self._service_account_email
diff --git a/pylint.config.py b/pylint.config.py
deleted file mode 100644
index d63f337..0000000
--- a/pylint.config.py
+++ /dev/null
@@ -1,71 +0,0 @@
-# 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.
-
-"""This module is used to config gcp-devrel-py-tools run-pylint."""
-
-import copy
-
-library_additions = {
-    'MESSAGES CONTROL': {
-        'disable': [
-            'I',
-            'import-error',
-            'no-member',
-            'protected-access',
-            'redefined-variable-type',
-            'similarities',
-            'no-else-return',
-        ],
-    },
-}
-
-library_replacements = {
-    'MASTER': {
-        'ignore': ['CVS', '.git', '.cache', '.tox', '.nox'],
-        'load-plugins': 'pylint.extensions.check_docs',
-    },
-    'REPORTS': {
-        'reports': 'no',
-    },
-    'BASIC': {
-        'method-rgx': '[a-z_][a-z0-9_]{2,40}$',
-        'function-rgx': '[a-z_][a-z0-9_]{2,40}$',
-    },
-    'TYPECHECK': {
-        'ignored-modules': ['six', 'google.protobuf'],
-    },
-    'DESIGN': {
-        'min-public-methods': '0',
-        'max-args': '10',
-        'max-attributes': '15',
-    },
-}
-
-test_additions = copy.deepcopy(library_additions)
-test_additions['MESSAGES CONTROL']['disable'].extend([
-    'missing-docstring',
-    'no-self-use',
-    'redefined-outer-name',
-    'unused-argument',
-    'no-name-in-module',
-])
-test_replacements = copy.deepcopy(library_replacements)
-test_replacements.setdefault('BASIC', {})
-test_replacements['BASIC'].update({
-    'good-names': ['i', 'j', 'k', 'ex', 'Run', '_', 'fh', 'pytestmark'],
-    'method-rgx': '[a-z_][a-z0-9_]{2,80}$',
-    'function-rgx': '[a-z_][a-z0-9_]{2,80}$',
-})
-
-ignored_files = ()
diff --git a/setup.py b/setup.py
index 9308707..0a21f25 100644
--- a/setup.py
+++ b/setup.py
@@ -19,8 +19,7 @@ from setuptools import setup
 
 
 DEPENDENCIES = (
-    'pyasn1>=0.1.7',
-    'pyasn1-modules>=0.0.5',
+    'pyasn1-modules>=0.2.1',
     'rsa>=3.1.4',
     'six>=1.9.0',
     'cachetools>=2.0.0',
@@ -32,7 +31,7 @@ with io.open('README.rst', 'r') as fh:
 
 setup(
     name='google-auth',
-    version='1.2.1',
+    version='1.3.0',
     author='Google Cloud Platform',
     author_email='jonwayne+google-auth@google.com',
     description='Google Authentication Library',
diff --git a/tox.ini b/tox.ini
index ddc6430..be124e6 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,6 +13,7 @@ deps =
   requests
   requests-oauthlib
   urllib3
+  cryptography
   grpcio; platform_python_implementation != 'PyPy'
 commands =
   py.test --cov=google.auth --cov=google.oauth2 --cov=tests {posargs:tests}
@@ -70,20 +71,14 @@ deps =
 commands = make -C docs html
 
 [testenv:lint]
-basepython = python3.5
+basepython = python3.6
 commands =
-  python setup.py check --metadata --restructuredtext --strict
   flake8 \
     --import-order-style=google \
     --application-import-names="google,tests,system_tests" \
     google tests
-  gcp-devrel-py-tools run-pylint \
-    --config pylint.config.py \
-    --library-filesets google \
-    --test-filesets tests system_tests
+  python setup.py check --metadata --restructuredtext --strict
 deps =
   flake8
   flake8-import-order
-  pylint
   docutils
-  gcp-devrel-py-tools


Test patch:
diff --git a/tests/crypt/test__cryptography_rsa.py b/tests/crypt/test__cryptography_rsa.py
new file mode 100644
index 0000000..a7ebb64
--- /dev/null
+++ b/tests/crypt/test__cryptography_rsa.py
@@ -0,0 +1,161 @@
+# 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 rsa
+import pytest
+
+from google.auth import _helpers
+from google.auth.crypt import _cryptography_rsa
+from google.auth.crypt import base
+
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
+
+# To generate privatekey.pem, privatekey.pub, and public_cert.pem:
+#   $ openssl req -new -newkey rsa:1024 -x509 -nodes -out public_cert.pem \
+#   >    -keyout privatekey.pem
+#   $ openssl rsa -in privatekey.pem -pubout -out privatekey.pub
+
+with open(os.path.join(DATA_DIR, 'privatekey.pem'), 'rb') as fh:
+    PRIVATE_KEY_BYTES = fh.read()
+    PKCS1_KEY_BYTES = PRIVATE_KEY_BYTES
+
+with open(os.path.join(DATA_DIR, 'privatekey.pub'), 'rb') as fh:
+    PUBLIC_KEY_BYTES = fh.read()
+
+with open(os.path.join(DATA_DIR, 'public_cert.pem'), 'rb') as fh:
+    PUBLIC_CERT_BYTES = fh.read()
+
+# To generate pem_from_pkcs12.pem and privatekey.p12:
+#   $ openssl pkcs12 -export -out privatekey.p12 -inkey privatekey.pem \
+#   >    -in public_cert.pem
+#   $ openssl pkcs12 -in privatekey.p12 -nocerts -nodes \
+#   >   -out pem_from_pkcs12.pem
+
+with open(os.path.join(DATA_DIR, 'pem_from_pkcs12.pem'), 'rb') as fh:
+    PKCS8_KEY_BYTES = fh.read()
+
+with open(os.path.join(DATA_DIR, 'privatekey.p12'), 'rb') as fh:
+    PKCS12_KEY_BYTES = fh.read()
+
+# The service account JSON file can be generated from the Google Cloud Console.
+SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, 'service_account.json')
+
+with open(SERVICE_ACCOUNT_JSON_FILE, 'r') as fh:
+    SERVICE_ACCOUNT_INFO = json.load(fh)
+
+
+class TestRSAVerifier(object):
+    def test_verify_success(self):
+        to_sign = b'foo'
+        signer = _cryptography_rsa.RSASigner.from_string(PRIVATE_KEY_BYTES)
+        actual_signature = signer.sign(to_sign)
+
+        verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES)
+        assert verifier.verify(to_sign, actual_signature)
+
+    def test_verify_unicode_success(self):
+        to_sign = u'foo'
+        signer = _cryptography_rsa.RSASigner.from_string(PRIVATE_KEY_BYTES)
+        actual_signature = signer.sign(to_sign)
+
+        verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES)
+        assert verifier.verify(to_sign, actual_signature)
+
+    def test_verify_failure(self):
+        verifier = _cryptography_rsa.RSAVerifier.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 = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_KEY_BYTES)
+        assert isinstance(verifier, _cryptography_rsa.RSAVerifier)
+        assert isinstance(verifier._pubkey, rsa.RSAPublicKey)
+
+    def test_from_string_pub_key_unicode(self):
+        public_key = _helpers.from_bytes(PUBLIC_KEY_BYTES)
+        verifier = _cryptography_rsa.RSAVerifier.from_string(public_key)
+        assert isinstance(verifier, _cryptography_rsa.RSAVerifier)
+        assert isinstance(verifier._pubkey, rsa.RSAPublicKey)
+
+    def test_from_string_pub_cert(self):
+        verifier = _cryptography_rsa.RSAVerifier.from_string(PUBLIC_CERT_BYTES)
+        assert isinstance(verifier, _cryptography_rsa.RSAVerifier)
+        assert isinstance(verifier._pubkey, rsa.RSAPublicKey)
+
+    def test_from_string_pub_cert_unicode(self):
+        public_cert = _helpers.from_bytes(PUBLIC_CERT_BYTES)
+        verifier = _cryptography_rsa.RSAVerifier.from_string(public_cert)
+        assert isinstance(verifier, _cryptography_rsa.RSAVerifier)
+        assert isinstance(verifier._pubkey, rsa.RSAPublicKey)
+
+
+class TestRSASigner(object):
+    def test_from_string_pkcs1(self):
+        signer = _cryptography_rsa.RSASigner.from_string(PKCS1_KEY_BYTES)
+        assert isinstance(signer, _cryptography_rsa.RSASigner)
+        assert isinstance(signer._key, rsa.RSAPrivateKey)
+
+    def test_from_string_pkcs1_unicode(self):
+        key_bytes = _helpers.from_bytes(PKCS1_KEY_BYTES)
+        signer = _cryptography_rsa.RSASigner.from_string(key_bytes)
+        assert isinstance(signer, _cryptography_rsa.RSASigner)
+        assert isinstance(signer._key, rsa.RSAPrivateKey)
+
+    def test_from_string_pkcs8(self):
+        signer = _cryptography_rsa.RSASigner.from_string(PKCS8_KEY_BYTES)
+        assert isinstance(signer, _cryptography_rsa.RSASigner)
+        assert isinstance(signer._key, rsa.RSAPrivateKey)
+
+    def test_from_string_pkcs8_unicode(self):
+        key_bytes = _helpers.from_bytes(PKCS8_KEY_BYTES)
+        signer = _cryptography_rsa.RSASigner.from_string(key_bytes)
+        assert isinstance(signer, _cryptography_rsa.RSASigner)
+        assert isinstance(signer._key, rsa.RSAPrivateKey)
+
+    def test_from_string_pkcs12(self):
+        with pytest.raises(ValueError):
+            _cryptography_rsa.RSASigner.from_string(PKCS12_KEY_BYTES)
+
+    def test_from_string_bogus_key(self):
+        key_bytes = 'bogus-key'
+        with pytest.raises(ValueError):
+            _cryptography_rsa.RSASigner.from_string(key_bytes)
+
+    def test_from_service_account_info(self):
+        signer = _cryptography_rsa.RSASigner.from_service_account_info(
+            SERVICE_ACCOUNT_INFO)
+
+        assert signer.key_id == SERVICE_ACCOUNT_INFO[
+            base._JSON_FILE_PRIVATE_KEY_ID]
+        assert isinstance(signer._key, rsa.RSAPrivateKey)
+
+    def test_from_service_account_info_missing_key(self):
+        with pytest.raises(ValueError) as excinfo:
+            _cryptography_rsa.RSASigner.from_service_account_info({})
+
+        assert excinfo.match(base._JSON_FILE_PRIVATE_KEY)
+
+    def test_from_service_account_file(self):
+        signer = _cryptography_rsa.RSASigner.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, rsa.RSAPrivateKey)
diff --git a/tests/crypt/test__python_rsa.py b/tests/crypt/test__python_rsa.py
index cff1034..d13105f 100644
--- a/tests/crypt/test__python_rsa.py
+++ b/tests/crypt/test__python_rsa.py
@@ -23,6 +23,7 @@ import six
 
 from google.auth import _helpers
 from google.auth.crypt import _python_rsa
+from google.auth.crypt import base
 
 
 DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
@@ -176,19 +177,19 @@ class TestRSASigner(object):
             SERVICE_ACCOUNT_INFO)
 
         assert signer.key_id == SERVICE_ACCOUNT_INFO[
-            _python_rsa._JSON_FILE_PRIVATE_KEY_ID]
+            base._JSON_FILE_PRIVATE_KEY_ID]
         assert isinstance(signer._key, rsa.key.PrivateKey)
 
     def test_from_service_account_info_missing_key(self):
         with pytest.raises(ValueError) as excinfo:
             _python_rsa.RSASigner.from_service_account_info({})
 
-        assert excinfo.match(_python_rsa._JSON_FILE_PRIVATE_KEY)
+        assert excinfo.match(base._JSON_FILE_PRIVATE_KEY)
 
     def test_from_service_account_file(self):
         signer = _python_rsa.RSASigner.from_service_account_file(
             SERVICE_ACCOUNT_JSON_FILE)
 
         assert signer.key_id == SERVICE_ACCOUNT_INFO[
-            _python_rsa._JSON_FILE_PRIVATE_KEY_ID]
+            base._JSON_FILE_PRIVATE_KEY_ID]
         assert isinstance(signer._key, rsa.key.PrivateKey)
diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py
index 6aeb3d1..3ec7fc6 100644
--- a/tests/oauth2/test__client.py
+++ b/tests/oauth2/test__client.py
@@ -14,6 +14,7 @@
 
 import datetime
 import json
+import os
 
 import mock
 import pytest
@@ -21,11 +22,22 @@ import six
 from six.moves import http_client
 from six.moves import urllib
 
+from google.auth import _helpers
+from google.auth import crypt
 from google.auth import exceptions
+from google.auth import jwt
 from google.auth import transport
 from google.oauth2 import _client
 
 
+DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
+
+with open(os.path.join(DATA_DIR, 'privatekey.pem'), 'rb') as fh:
+    PRIVATE_KEY_BYTES = fh.read()
+
+SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1')
+
+
 def test__handle_error_response():
     response_data = json.dumps({
         'error': 'help',
@@ -129,6 +141,42 @@ def test_jwt_grant_no_access_token():
         _client.jwt_grant(request, 'http://example.com', 'assertion_value')
 
 
+def test_id_token_jwt_grant():
+    now = _helpers.utcnow()
+    id_token_expiry = _helpers.datetime_to_secs(now)
+    id_token = jwt.encode(SIGNER, {'exp': id_token_expiry}).decode('utf-8')
+    request = make_request({
+        'id_token': id_token,
+        'extra': 'data'})
+
+    token, expiry, extra_data = _client.id_token_jwt_grant(
+        request, 'http://example.com', 'assertion_value')
+
+    # Check request call
+    verify_request_params(request, {
+        'grant_type': _client._JWT_GRANT_TYPE,
+        'assertion': 'assertion_value'
+    })
+
+    # Check result
+    assert token == id_token
+    # JWT does not store microseconds
+    now = now.replace(microsecond=0)
+    assert expiry == now
+    assert extra_data['extra'] == 'data'
+
+
+def test_id_token_jwt_grant_no_access_token():
+    request = make_request({
+        # No access token.
+        'expires_in': 500,
+        'extra': 'data'})
+
+    with pytest.raises(exceptions.RefreshError):
+        _client.id_token_jwt_grant(
+            request, 'http://example.com', 'assertion_value')
+
+
 @mock.patch('google.auth._helpers.utcnow', return_value=datetime.datetime.min)
 def test_refresh_grant(unused_utcnow):
     request = make_request({
diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py
index 5e09d6f..9064363 100644
--- a/tests/oauth2/test_credentials.py
+++ b/tests/oauth2/test_credentials.py
@@ -13,6 +13,8 @@
 # limitations under the License.
 
 import datetime
+import json
+import os
 
 import mock
 
@@ -21,6 +23,14 @@ from google.auth import transport
 from google.oauth2 import credentials
 
 
+DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
+
+AUTH_USER_JSON_FILE = os.path.join(DATA_DIR, 'authorized_user.json')
+
+with open(AUTH_USER_JSON_FILE, 'r') as fh:
+    AUTH_USER_INFO = json.load(fh)
+
+
 class TestCredentials(object):
     TOKEN_URI = 'https://example.com/oauth2/token'
     REFRESH_TOKEN = 'refresh_token'
@@ -84,3 +94,42 @@ class TestCredentials(object):
         # Check that the credentials are valid (have a token and are not
         # expired)
         assert credentials.valid
+
+    def test_from_authorized_user_info(self):
+        info = AUTH_USER_INFO.copy()
+
+        creds = credentials.Credentials.from_authorized_user_info(info)
+        assert creds.client_secret == info['client_secret']
+        assert creds.client_id == info['client_id']
+        assert creds.refresh_token == info['refresh_token']
+        assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT
+        assert creds.scopes is None
+
+        scopes = ['email', 'profile']
+        creds = credentials.Credentials.from_authorized_user_info(
+            info, scopes)
+        assert creds.client_secret == info['client_secret']
+        assert creds.client_id == info['client_id']
+        assert creds.refresh_token == info['refresh_token']
+        assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT
+        assert creds.scopes == scopes
+
+    def test_from_authorized_user_file(self):
+        info = AUTH_USER_INFO.copy()
+
+        creds = credentials.Credentials.from_authorized_user_file(
+            AUTH_USER_JSON_FILE)
+        assert creds.client_secret == info['client_secret']
+        assert creds.client_id == info['client_id']
+        assert creds.refresh_token == info['refresh_token']
+        assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT
+        assert creds.scopes is None
+
+        scopes = ['email', 'profile']
+        creds = credentials.Credentials.from_authorized_user_file(
+            AUTH_USER_JSON_FILE, scopes)
+        assert creds.client_secret == info['client_secret']
+        assert creds.client_id == info['client_id']
+        assert creds.refresh_token == info['refresh_token']
+        assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT
+        assert creds.scopes == scopes
diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py
index 9c235db..54ac0f5 100644
--- a/tests/oauth2/test_service_account.py
+++ b/tests/oauth2/test_service_account.py
@@ -216,3 +216,126 @@ class TestCredentials(object):
 
         # Credentials should now be valid.
         assert credentials.valid
+
+
+class TestIDTokenCredentials(object):
+    SERVICE_ACCOUNT_EMAIL = 'service-account@example.com'
+    TOKEN_URI = 'https://example.com/oauth2/token'
+    TARGET_AUDIENCE = 'https://example.com'
+
+    @classmethod
+    def make_credentials(cls):
+        return service_account.IDTokenCredentials(
+            SIGNER, cls.SERVICE_ACCOUNT_EMAIL, cls.TOKEN_URI,
+            cls.TARGET_AUDIENCE)
+
+    def test_from_service_account_info(self):
+        credentials = (
+            service_account.IDTokenCredentials.from_service_account_info(
+                SERVICE_ACCOUNT_INFO,
+                target_audience=self.TARGET_AUDIENCE))
+
+        assert (credentials._signer.key_id ==
+                SERVICE_ACCOUNT_INFO['private_key_id'])
+        assert (credentials.service_account_email ==
+                SERVICE_ACCOUNT_INFO['client_email'])
+        assert credentials._token_uri == SERVICE_ACCOUNT_INFO['token_uri']
+        assert credentials._target_audience == self.TARGET_AUDIENCE
+
+    def test_from_service_account_file(self):
+        info = SERVICE_ACCOUNT_INFO.copy()
+
+        credentials = (
+                service_account.IDTokenCredentials.from_service_account_file(
+                    SERVICE_ACCOUNT_JSON_FILE,
+                    target_audience=self.TARGET_AUDIENCE))
+
+        assert credentials.service_account_email == info['client_email']
+        assert credentials._signer.key_id == info['private_key_id']
+        assert credentials._token_uri == info['token_uri']
+        assert credentials._target_audience == self.TARGET_AUDIENCE
+
+    def test_default_state(self):
+        credentials = self.make_credentials()
+        assert not credentials.valid
+        # Expiration hasn't been set yet
+        assert not credentials.expired
+
+    def test_sign_bytes(self):
+        credentials = self.make_credentials()
+        to_sign = b'123'
+        signature = credentials.sign_bytes(to_sign)
+        assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES)
+
+    def test_signer(self):
+        credentials = self.make_credentials()
+        assert isinstance(credentials.signer, crypt.Signer)
+
+    def test_signer_email(self):
+        credentials = self.make_credentials()
+        assert credentials.signer_email == self.SERVICE_ACCOUNT_EMAIL
+
+    def test_with_target_audience(self):
+        credentials = self.make_credentials()
+        new_credentials = credentials.with_target_audience(
+            'https://new.example.com')
+        assert new_credentials._target_audience == 'https://new.example.com'
+
+    def test__make_authorization_grant_assertion(self):
+        credentials = self.make_credentials()
+        token = credentials._make_authorization_grant_assertion()
+        payload = jwt.decode(token, PUBLIC_CERT_BYTES)
+        assert payload['iss'] == self.SERVICE_ACCOUNT_EMAIL
+        assert payload['aud'] == self.TOKEN_URI
+        assert payload['target_audience'] == self.TARGET_AUDIENCE
+
+    @mock.patch('google.oauth2._client.id_token_jwt_grant', autospec=True)
+    def test_refresh_success(self, id_token_jwt_grant):
+        credentials = self.make_credentials()
+        token = 'token'
+        id_token_jwt_grant.return_value = (
+            token,
+            _helpers.utcnow() + datetime.timedelta(seconds=500),
+            {})
+        request = mock.create_autospec(transport.Request, instance=True)
+
+        # Refresh credentials
+        credentials.refresh(request)
+
+        # Check jwt grant call.
+        assert id_token_jwt_grant.called
+
+        called_request, token_uri, assertion = id_token_jwt_grant.call_args[0]
+        assert called_request == request
+        assert token_uri == credentials._token_uri
+        assert jwt.decode(assertion, PUBLIC_CERT_BYTES)
+        # No further assertion done on the token, as there are separate tests
+        # for checking the authorization grant assertion.
+
+        # Check that the credentials have the token.
+        assert credentials.token == token
+
+        # Check that the credentials are valid (have a token and are not
+        # expired)
+        assert credentials.valid
+
+    @mock.patch('google.oauth2._client.id_token_jwt_grant', autospec=True)
+    def test_before_request_refreshes(self, id_token_jwt_grant):
+        credentials = self.make_credentials()
+        token = 'token'
+        id_token_jwt_grant.return_value = (
+            token, _helpers.utcnow() + datetime.timedelta(seconds=500), None)
+        request = mock.create_autospec(transport.Request, instance=True)
+
+        # 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', {})
+
+        # The refresh endpoint should've been called.
+        assert id_token_jwt_grant.called
+
+        # Credentials should now be valid.
+        assert credentials.valid
diff --git a/tests/test__cloud_sdk.py b/tests/test__cloud_sdk.py
index c14fc20..58c7270 100644
--- a/tests/test__cloud_sdk.py
+++ b/tests/test__cloud_sdk.py
@@ -145,7 +145,8 @@ def test_load_authorized_user_credentials():
     assert credentials._client_id == AUTHORIZED_USER_FILE_DATA['client_id']
     assert (credentials._client_secret ==
             AUTHORIZED_USER_FILE_DATA['client_secret'])
-    assert credentials._token_uri == _cloud_sdk._GOOGLE_OAUTH2_TOKEN_ENDPOINT
+    assert (credentials._token_uri ==
+            google.oauth2.credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT)
 
 
 def test_load_authorized_user_credentials_bad_format():
diff --git a/tests/test__default.py b/tests/test__default.py
index 2df8a44..68c4fb0 100644
--- a/tests/test__default.py
+++ b/tests/test__default.py
@@ -301,6 +301,28 @@ def test_default_explict_legacy_project_id(unused_get, monkeypatch):
         mock.sentinel.credentials, 'explicit-env')
 
 
+@mock.patch(
+    'logging.Logger.warning',
+    autospec=True)
+@mock.patch(
+    'google.auth._default._get_explicit_environ_credentials',
+    return_value=(mock.sentinel.credentials, None), autospec=True)
+@mock.patch(
+    'google.auth._default._get_gcloud_sdk_credentials',
+    return_value=(mock.sentinel.credentials, None), autospec=True)
+@mock.patch(
+    'google.auth._default._get_gae_credentials',
+    return_value=(mock.sentinel.credentials, None), autospec=True)
+@mock.patch(
+    'google.auth._default._get_gce_credentials',
+    return_value=(mock.sentinel.credentials, None), autospec=True)
+def test_default_without_project_id(
+        unused_gce, unused_gae, unused_sdk, unused_explicit, logger_warning):
+    assert _default.default() == (
+        mock.sentinel.credentials, None)
+    logger_warning.assert_called_with(mock.ANY, mock.ANY, mock.ANY)
+
+
 @mock.patch(
     'google.auth._default._get_explicit_environ_credentials',
     return_value=(None, None), autospec=True)
@@ -324,7 +346,7 @@ def test_default_fail(unused_gce, unused_gae, unused_sdk, unused_explicit):
     autospec=True)
 @mock.patch(
     'google.auth.credentials.with_scopes_if_required', autospec=True)
-def test_default_scoped(with_scopes, get):
+def test_default_scoped(with_scopes, unused_get):
     scopes = ['one', 'two']
 
     credentials, project_id = _default.default(scopes=scopes)
diff --git a/tests/test__service_account_info.py b/tests/test__service_account_info.py
index 5466865..ef41e27 100644
--- a/tests/test__service_account_info.py
+++ b/tests/test__service_account_info.py
@@ -42,7 +42,7 @@ def test_from_dict_bad_private_key():
     with pytest.raises(ValueError) as excinfo:
         _service_account_info.from_dict(info)
 
-    assert excinfo.match(r'No key could be detected')
+    assert excinfo.match(r'key')
 
 
 def test_from_dict_bad_format():