Repository: box/box-python-sdk

Issue:
Add support to load app config file downloaded from dev console
Add support to jwt_auth.py to load a box_config.json file downloaded from the developer console. This would make the python SDK consistent with the node an java sdk's.

node: https://github.com/box/box-node-sdk/blob/3dcfe69ed1354ed85b54eb4b1d4e179c999f4f99/lib/box-node-sdk.js#L126

java: 
https://github.com/box/box-java-sdk/blob/master/src/main/java/com/box/sdk/BoxConfig.java
https://github.com/box/box-java-sdk/blob/da3332d77c09f1c75afe93184b2a02bb00e84dd8/src/main/java/com/box/sdk/BoxDeveloperEditionAPIConnection.java#L189

Discussion:
boxcla: Verified that @Jeff-Meadows has signed the CLA. Thanks for the pull request!

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 5cf6878..c75fe80 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -105,6 +105,8 @@ Release History
 - Added a ``downscope_token()`` method to the ``OAuth2`` class. This generates a token that
   has its permissions reduced to the provided scopes and for the optionally provided 
   ``File`` or ``Folder``.
+- Added methods for configuring ``JWTAuth`` from config file: ``JWTAuth.from_settings_file`` and
+  ``JWTAuth.from_settings_dictionary``.
 
 **Other**
 
diff --git a/boxsdk/auth/jwt_auth.py b/boxsdk/auth/jwt_auth.py
index 6b27618..3a85130 100644
--- a/boxsdk/auth/jwt_auth.py
+++ b/boxsdk/auth/jwt_auth.py
@@ -3,6 +3,7 @@
 from __future__ import absolute_import, unicode_literals
 
 from datetime import datetime, timedelta
+import json
 import random
 import string
 
@@ -360,3 +361,42 @@ def _normalize_rsa_private_key_passphrase(passphrase):
                 .format(passphrase.__class__.__name__)
             )
         return passphrase
+
+    @classmethod
+    def from_settings_dictionary(cls, settings_dictionary, **kwargs):
+        """
+        Create an auth instance as defined by the given settings dictionary.
+
+        The dictionary should have the structure of the JSON file downloaded from the Box Developer Console.
+
+        :param settings_dictionary:       Dictionary containing settings for configuring app auth.
+        :type settings_dictionary:        `dict`
+        :return:                        Auth instance configured as specified by the config dictionary.
+        :rtype:                         :class:`JWTAuth`
+        """
+        if 'boxAppSettings' not in settings_dictionary:
+            raise ValueError('boxAppSettings not present in configuration')
+        return cls(
+            client_id=settings_dictionary['boxAppSettings']['clientID'],
+            client_secret=settings_dictionary['boxAppSettings']['clientSecret'],
+            enterprise_id=settings_dictionary.get('enterpriseID', None),
+            jwt_key_id=settings_dictionary['boxAppSettings']['appAuth'].get('publicKeyID', None),
+            rsa_private_key_data=settings_dictionary['boxAppSettings']['appAuth'].get('privateKey', None),
+            rsa_private_key_passphrase=settings_dictionary['boxAppSettings']['appAuth'].get('passphrase', None),
+            **kwargs
+        )
+
+    @classmethod
+    def from_settings_file(cls, settings_file_sys_path, **kwargs):
+        """
+        Create an auth instance as defined by a JSON file downloaded from the Box Developer Console.
+        See https://developer.box.com/v2.0/docs/authentication-with-jwt for more information.
+
+        :param settings_file_sys_path:    Path to the JSON file containing the configuration.
+        :type settings_file_sys_path:     `unicode`
+        :return:                        Auth instance configured as specified by the JSON file.
+        :rtype:                         :class:`JWTAuth`
+        """
+        with open(settings_file_sys_path) as config_file:
+            config_dictionary = json.load(config_file)
+            return cls.from_settings_dictionary(config_dictionary, **kwargs)


Test patch:
diff --git a/test/unit/auth/test_jwt_auth.py b/test/unit/auth/test_jwt_auth.py
index 830dfe0..4512397 100644
--- a/test/unit/auth/test_jwt_auth.py
+++ b/test/unit/auth/test_jwt_auth.py
@@ -364,3 +364,100 @@ def test_refresh_instance_sends_post_request_with_correct_params(jwt_auth_init_a
     enterprise_id = 'fake_enterprise_id'
     with jwt_auth_init_and_auth_mocks(enterprise_id, 'enterprise', enterprise_id=enterprise_id) as oauth:
         oauth.refresh(None)
+
+
+@pytest.fixture()
+def jwt_subclass_that_just_stores_params():
+    class StoreParamJWTAuth(JWTAuth):
+        def __init__(self, **kwargs):
+            self.kwargs = kwargs
+            super(StoreParamJWTAuth, self).__init__(**kwargs)
+
+    return StoreParamJWTAuth
+
+
+@pytest.fixture
+def fake_client_id():
+    return 'fake_client_id'
+
+
+@pytest.fixture
+def fake_client_secret():
+    return 'fake_client_secret'
+
+
+@pytest.fixture
+def fake_enterprise_id():
+    return 'fake_enterprise_id'
+
+
+@pytest.fixture
+def app_config_json_content(
+        fake_client_id,
+        fake_client_secret,
+        fake_enterprise_id,
+        jwt_key_id,
+        rsa_private_key_bytes,
+        rsa_passphrase,
+):
+    template = r"""
+{{
+  "boxAppSettings": {{
+    "clientID": "{client_id}",
+    "clientSecret": "{client_secret}",
+    "appAuth": {{
+      "publicKeyID": "{jwt_key_id}",
+      "privateKey": "{private_key}",
+      "passphrase": {passphrase}
+    }}
+  }},
+  "enterpriseID": {enterprise_id}
+}}"""
+    return template.format(
+        client_id=fake_client_id,
+        client_secret=fake_client_secret,
+        jwt_key_id=jwt_key_id,
+        private_key=rsa_private_key_bytes.replace(b"\n", b"\\n").decode(),
+        passphrase=json.dumps(rsa_passphrase and rsa_passphrase.decode()),
+        enterprise_id=json.dumps(fake_enterprise_id),
+    )
+
+
+@pytest.fixture()
+def assert_jwt_kwargs_expected(
+        fake_client_id,
+        fake_client_secret,
+        fake_enterprise_id,
+        jwt_key_id,
+        rsa_private_key_bytes,
+        rsa_passphrase,
+):
+    def _assert_jwt_kwargs_expected(jwt_auth):
+        assert jwt_auth.kwargs['client_id'] == fake_client_id
+        assert jwt_auth.kwargs['client_secret'] == fake_client_secret
+        assert jwt_auth.kwargs['enterprise_id'] == fake_enterprise_id
+        assert jwt_auth.kwargs['jwt_key_id'] == jwt_key_id
+        assert jwt_auth.kwargs['rsa_private_key_data'] == rsa_private_key_bytes.decode()
+        assert jwt_auth.kwargs['rsa_private_key_passphrase'] == (rsa_passphrase and rsa_passphrase.decode())
+
+    return _assert_jwt_kwargs_expected
+
+
+def test_from_config_file(
+        jwt_subclass_that_just_stores_params,
+        app_config_json_content,
+        assert_jwt_kwargs_expected,
+):
+    # pylint:disable=redefined-outer-name
+    with patch('boxsdk.auth.jwt_auth.open', mock_open(read_data=app_config_json_content), create=True):
+        jwt_auth_from_config_file = jwt_subclass_that_just_stores_params.from_settings_file('fake_config_file_sys_path')
+        assert_jwt_kwargs_expected(jwt_auth_from_config_file)
+
+
+def test_from_settings_dictionary(
+        jwt_subclass_that_just_stores_params,
+        app_config_json_content,
+        assert_jwt_kwargs_expected,
+):
+    jwt_auth_from_dictionary = jwt_subclass_that_just_stores_params.from_settings_dictionary(json.loads(app_config_json_content))
+    assert_jwt_kwargs_expected(jwt_auth_from_dictionary)