Repository: pydata/pydata-google-auth

Issue:
ENH: Support authentication with service accounts
Service accounts use a different credentials class (`google.oauth2.service_account.Credentials`) and mostly used within a file that is expored from GCP console. It would be nice to have a `pydata_google_auth.load_service_account_credentials(path: os.PathLike) -> Credentials` method which does the initalization.

Comments:

Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 24e03ed..f480e1a 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -11,6 +11,7 @@ API Reference
     get_user_credentials
     load_user_credentials
     save_user_credentials
+    load_service_account_credentials
     cache.CredentialsCache
     cache.READ_WRITE
     cache.REAUTH
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index 71747be..aa1a9a6 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -1,6 +1,12 @@
 Changelog
 =========
 
+Unreleased / TBD
+----------------
+
+- Adds :func:`pydata_google_auth.load_service_account_credentials` function to
+  get service account credentials from the specified JSON path. (:issue:`39`)
+
 .. _changelog-1.1.0:
 
 1.1.0 / (2020-04-23)
diff --git a/pydata_google_auth/__init__.py b/pydata_google_auth/__init__.py
index dafec61..9ea95f5 100644
--- a/pydata_google_auth/__init__.py
+++ b/pydata_google_auth/__init__.py
@@ -2,6 +2,7 @@ from .auth import default
 from .auth import get_user_credentials
 from .auth import load_user_credentials
 from .auth import save_user_credentials
+from .auth import load_service_account_credentials
 from ._version import get_versions
 
 versions = get_versions()
@@ -20,4 +21,5 @@ __all__ = [
     "get_user_credentials",
     "load_user_credentials",
     "save_user_credentials",
+    "load_service_account_credentials",
 ]
diff --git a/pydata_google_auth/auth.py b/pydata_google_auth/auth.py
index 6c9a7ba..7ac86ce 100644
--- a/pydata_google_auth/auth.py
+++ b/pydata_google_auth/auth.py
@@ -402,3 +402,51 @@ def load_user_credentials(path):
     if not credentials:
         raise exceptions.PyDataCredentialsError("Could not load credentials.")
     return credentials
+
+
+def load_service_account_credentials(path, scopes=None):
+    """
+    Gets service account credentials from JSON file at ``path``.
+
+    Parameters
+    ----------
+    path : str
+        Path to credentials JSON file.
+    scopes : list[str], optional
+        A list of scopes to use when authenticating to Google APIs. See the
+        `list of OAuth 2.0 scopes for Google APIs
+        <https://developers.google.com/identity/protocols/googlescopes>`_.
+
+    Returns
+    -------
+
+    google.oauth2.service_account.Credentials
+
+    Raises
+    ------
+    pydata_google_auth.exceptions.PyDataCredentialsError
+        If unable to load service credentials.
+
+    Examples
+    --------
+
+    Load credentials and use them to construct a BigQuery client.
+
+    .. code-block:: python
+
+       import pydata_google_auth
+       import google.cloud.bigquery
+
+       credentials = pydata_google_auth.load_service_account_credentials(
+           "/home/username/keys/google-service-account-credentials.json",
+       )
+       client = google.cloud.bigquery.BigQueryClient(
+           credentials=credentials,
+           project=credentials.project_id
+       )
+    """
+
+    credentials = cache._load_service_account_credentials_from_file(path, scopes=scopes)
+    if not credentials:
+        raise exceptions.PyDataCredentialsError("Could not load credentials.")
+    return credentials
diff --git a/pydata_google_auth/cache.py b/pydata_google_auth/cache.py
index cad9097..16051a3 100644
--- a/pydata_google_auth/cache.py
+++ b/pydata_google_auth/cache.py
@@ -7,6 +7,7 @@ import os
 import os.path
 
 import google.oauth2.credentials
+from google.oauth2 import service_account
 
 
 logger = logging.getLogger(__name__)
@@ -123,6 +124,34 @@ def _save_user_account_credentials(credentials, credentials_path):
         logger.warning("Unable to save credentials.")
 
 
+def _load_service_account_credentials_from_file(credentials_path, **kwargs):
+    try:
+        with open(credentials_path) as credentials_file:
+            credentials_json = json.load(credentials_file)
+    except (IOError, ValueError) as exc:
+        logger.debug(
+            "Error loading credentials from {}: {}".format(credentials_path, str(exc))
+        )
+        return None
+
+    return _load_service_account_credentials_from_info(credentials_json, **kwargs)
+
+
+def _load_service_account_credentials_from_info(credentials_json, **kwargs):
+    credentials = service_account.Credentials.from_service_account_info(
+        credentials_json, **kwargs
+    )
+    if not credentials.valid:
+        request = google.auth.transport.requests.Request()
+        try:
+            credentials.refresh(request)
+        except google.auth.exceptions.RefreshError:
+            # Credentials could be expired or revoked.
+            return None
+
+    return credentials
+
+
 class CredentialsCache(object):
     """
     Shared base class for crentials classes.


Test patch:
diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py
index 7eadd2c..993d02b 100644
--- a/tests/unit/test_auth.py
+++ b/tests/unit/test_auth.py
@@ -10,6 +10,7 @@ import google.auth.credentials
 import google.oauth2.credentials
 import pytest
 
+from google.oauth2 import service_account
 from pydata_google_auth import exceptions
 
 
@@ -56,6 +57,33 @@ def test_default_loads_user_credentials(monkeypatch, module_under_test):
     assert credentials is mock_user_credentials
 
 
+class FakeCredentials(object):
+    @property
+    def valid(self):
+        return True
+
+
+def test_load_service_account_credentials(monkeypatch, tmp_path, module_under_test):
+    creds_path = str(tmp_path / "creds.json")
+    with open(creds_path, "w") as stream:
+        stream.write("{}")
+
+    fake_creds = FakeCredentials()
+    mock_service = mock.create_autospec(service_account.Credentials)
+    mock_service.from_service_account_info.return_value = fake_creds
+    monkeypatch.setattr(service_account, "Credentials", mock_service)
+
+    creds = module_under_test.load_service_account_credentials(creds_path)
+    assert creds is fake_creds
+
+
 def test_load_user_credentials_raises_when_file_doesnt_exist(module_under_test):
     with pytest.raises(exceptions.PyDataCredentialsError):
         module_under_test.load_user_credentials("path/not/found.json")
+
+
+def test_load_service_account_credentials_raises_when_file_doesnt_exist(
+    module_under_test,
+):
+    with pytest.raises(exceptions.PyDataCredentialsError):
+        module_under_test.load_service_account_credentials("path/not/found.json")