Repository: googleapis/google-auth-library-python

Issue:
Consolidate service account key file loading logic.



Discussion:

Resolving patch:
diff --git a/google/auth/_service_account_info.py b/google/auth/_service_account_info.py
new file mode 100644
--- /dev/null
+++ b/google/auth/_service_account_info.py
@@ -0,0 +1,77 @@
+# 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.
+
+"""Helper functions for loading data from a Google service account file."""
+
+import io
+import json
+
+import six
+
+from google.auth import crypt
+
+
+def from_dict(data, require=None):
+    """Validates a dictionary containing Google service account data.
+
+    Creates and returns a :class:`google.auth.crypt.Signer` instance from the
+    private key specified in the data.
+
+    Args:
+        data (Mapping[str, str]): The service account data
+        require (Sequence[str]): List of keys required to be present in the
+            info.
+
+    Returns:
+        google.auth.crypt.Signer: A signer created from the private key in the
+            service account file.
+
+    Raises:
+        ValueError: if the data was in the wrong format, or if one of the
+            required keys is missing.
+    """
+    # Private key is always required.
+    keys_needed = set(('private_key',))
+    if require is not None:
+        keys_needed.update(require)
+
+    missing = keys_needed.difference(six.iterkeys(data))
+
+    if missing:
+        raise ValueError(
+            'Service account info was not in the expected format, missing '
+            'fields {}.'.format(', '.join(missing)))
+
+    # Create a signer.
+    signer = crypt.Signer.from_string(
+        data['private_key'], data.get('private_key_id'))
+
+    return signer
+
+
+def from_filename(filename, require=None):
+    """Reads a Google service account JSON file and returns its parsed info.
+
+    Args:
+        filename (str): The path to the service account .json file.
+        require (Sequence[str]): List of keys required to be present in the
+            info.
+
+    Returns:
+        Tuple[ Mapping[str, str], google.auth.crypt.Signer ]: The verified
+            info and a signer instance.
+    """
+    with io.open(filename, 'r', encoding='utf-8') as json_file:
+        data = json.load(json_file)
+        return data, from_dict(data, require=require)
diff --git a/google/auth/jwt.py b/google/auth/jwt.py
--- a/google/auth/jwt.py
+++ b/google/auth/jwt.py
@@ -43,12 +43,12 @@
 import base64
 import collections
 import datetime
-import io
 import json
 
 from six.moves import urllib
 
 from google.auth import _helpers
+from google.auth import _service_account_info
 from google.auth import credentials
 from google.auth import crypt
 
@@ -314,12 +314,13 @@ def __init__(self, signer, issuer=None, subject=None, audience=None,
         self._token_lifetime = token_lifetime
 
     @classmethod
-    def from_service_account_info(cls, info, **kwargs):
-        """Creates a Credentials instance from parsed service account info.
+    def _from_signer_and_info(cls, signer, info, **kwargs):
+        """Creates a Credentials instance from a signer and service account
+        info.
 
         Args:
-            info (Mapping[str, str]): The service account info in Google
-                format.
+            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:
@@ -328,34 +329,44 @@ def from_service_account_info(cls, info, **kwargs):
         Raises:
             ValueError: If the info is not in the expected format.
         """
+        kwargs.setdefault('subject', info['client_email'])
+        return cls(signer, issuer=info['client_email'], **kwargs)
 
-        try:
-            email = info['client_email']
-            key_id = info['private_key_id']
-            private_key = info['private_key']
-        except KeyError:
-            raise ValueError(
-                'Service account info was not in the expected format.')
+    @classmethod
+    def from_service_account_info(cls, info, **kwargs):
+        """Creates a Credentials instance from a dictionary containing service
+        account info in Google format.
 
-        signer = crypt.Signer.from_string(private_key, key_id)
+        Args:
+            info (Mapping[str, str]): The service account info in Google
+                format.
+            kwargs: Additional arguments to pass to the constructor.
+
+        Returns:
+            google.auth.jwt.Credentials: The constructed credentials.
 
-        kwargs.setdefault('subject', email)
-        return cls(signer, issuer=email, **kwargs)
+        Raises:
+            ValueError: If the info is not in the expected format.
+        """
+        signer = _service_account_info.from_dict(
+            info, require=['client_email'])
+        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.
+        """Creates a Credentials instance from a service account .json file
+        in Google format.
 
         Args:
-            filename (str): The path to the service account json file.
+            filename (str): The path to the service account .json file.
             kwargs: Additional arguments to pass to the constructor.
 
         Returns:
             google.auth.jwt.Credentials: The constructed credentials.
         """
-        with io.open(filename, 'r', encoding='utf-8') as json_file:
-            info = json.load(json_file)
-        return cls.from_service_account_info(info, **kwargs)
+        info, signer = _service_account_info.from_filename(
+            filename, require=['client_email'])
+        return cls._from_signer_and_info(signer, info, **kwargs)
 
     def with_claims(self, issuer=None, subject=None, audience=None,
                     additional_claims=None):
diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py
--- a/google/oauth2/service_account.py
+++ b/google/oauth2/service_account.py
@@ -71,12 +71,10 @@
 """
 
 import datetime
-import io
-import json
 
 from google.auth import _helpers
+from google.auth import _service_account_info
 from google.auth import credentials
-from google.auth import crypt
 from google.auth import jwt
 from google.oauth2 import _client
 
@@ -149,6 +147,27 @@ def __init__(self, signer, service_account_email, token_uri, scopes=None,
         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.Credentials: The constructed credentials.
+
+        Raises:
+            ValueError: If the info is not in the expected format.
+        """
+        return cls(
+            signer,
+            service_account_email=info['client_email'],
+            token_uri=info['token_uri'], **kwargs)
+
     @classmethod
     def from_service_account_info(cls, info, **kwargs):
         """Creates a Credentials instance from parsed service account info.
@@ -165,19 +184,9 @@ def from_service_account_info(cls, info, **kwargs):
         Raises:
             ValueError: If the info is not in the expected format.
         """
-        try:
-            email = info['client_email']
-            key_id = info['private_key_id']
-            private_key = info['private_key']
-            token_uri = info['token_uri']
-        except KeyError:
-            raise ValueError(
-                'Service account info was not in the expected format.')
-
-        signer = crypt.Signer.from_string(private_key, key_id)
-
-        return cls(
-            signer, service_account_email=email, token_uri=token_uri, **kwargs)
+        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):
@@ -191,9 +200,9 @@ def from_service_account_file(cls, filename, **kwargs):
             google.auth.service_account.Credentials: The constructed
                 credentials.
         """
-        with io.open(filename, 'r', encoding='utf-8') as json_file:
-            info = json.load(json_file)
-        return cls.from_service_account_info(info, **kwargs)
+        info, signer = _service_account_info.from_filename(
+            filename, require=['client_email', 'token_uri'])
+        return cls._from_signer_and_info(signer, info, **kwargs)
 
     @property
     def requires_scopes(self):


Test patch:
diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py
--- a/tests/oauth2/test_service_account.py
+++ b/tests/oauth2/test_service_account.py
@@ -85,19 +85,6 @@ def test_from_service_account_info_args(self):
         assert credentials._subject == subject
         assert credentials._additional_claims == additional_claims
 
-    def test_from_service_account_bad_key(self):
-        info = SERVICE_ACCOUNT_INFO.copy()
-        info['private_key'] = 'garbage'
-
-        with pytest.raises(ValueError) as excinfo:
-            service_account.Credentials.from_service_account_info(info)
-
-        assert excinfo.match(r'No key could be detected')
-
-    def test_from_service_account_bad_format(self):
-        with pytest.raises(ValueError):
-            service_account.Credentials.from_service_account_info({})
-
     def test_from_service_account_file(self):
         info = SERVICE_ACCOUNT_INFO.copy()
 
diff --git a/tests/test__service_account_info.py b/tests/test__service_account_info.py
new file mode 100644
--- /dev/null
+++ b/tests/test__service_account_info.py
@@ -0,0 +1,63 @@
+# 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
+
+import pytest
+import six
+
+from google.auth import _service_account_info
+from google.auth import crypt
+
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
+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)
+
+
+def test_from_dict():
+    signer = _service_account_info.from_dict(SERVICE_ACCOUNT_INFO)
+    assert isinstance(signer, crypt.Signer)
+    assert signer.key_id == SERVICE_ACCOUNT_INFO['private_key_id']
+
+
+def test_from_dict_bad_private_key():
+    info = SERVICE_ACCOUNT_INFO.copy()
+    info['private_key'] = 'garbage'
+
+    with pytest.raises(ValueError) as excinfo:
+        _service_account_info.from_dict(info)
+
+    assert excinfo.match(r'No key could be detected')
+
+
+def test_from_dict_bad_format():
+    with pytest.raises(ValueError) as excinfo:
+        _service_account_info.from_dict({})
+
+    assert excinfo.match(r'missing fields')
+
+
+def test_from_filename():
+    info, signer = _service_account_info.from_filename(
+        SERVICE_ACCOUNT_JSON_FILE)
+
+    for key, value in six.iteritems(SERVICE_ACCOUNT_INFO):
+        assert info[key] == value
+
+    assert isinstance(signer, crypt.Signer)
+    assert signer.key_id == SERVICE_ACCOUNT_INFO['private_key_id']
diff --git a/tests/test_jwt.py b/tests/test_jwt.py
--- a/tests/test_jwt.py
+++ b/tests/test_jwt.py
@@ -231,19 +231,6 @@ def test_from_service_account_info_args(self):
         assert credentials._audience == self.AUDIENCE
         assert credentials._additional_claims == self.ADDITIONAL_CLAIMS
 
-    def test_from_service_account_bad_private_key(self):
-        info = SERVICE_ACCOUNT_INFO.copy()
-        info['private_key'] = 'garbage'
-
-        with pytest.raises(ValueError) as excinfo:
-            jwt.Credentials.from_service_account_info(info)
-
-        assert excinfo.match(r'No key could be detected')
-
-    def test_from_service_account_bad_format(self):
-        with pytest.raises(ValueError):
-            jwt.Credentials.from_service_account_info({})
-
     def test_from_service_account_file(self):
         info = SERVICE_ACCOUNT_INFO.copy()